Skip to content

feat(tui): add terminal dashboard for SMG - #867

Merged
key4ng merged 8 commits into
mainfrom
keyang/tui-redesign
Mar 26, 2026
Merged

key4ng merged 8 commits into
mainfrom
keyang/tui-redesign

Conversation

@key4ng

@key4ng key4ng commented Mar 22, 2026 •

Copy link
Copy Markdown
Member

Description

Problem

SMG lacks a visual interface for monitoring workers, managing routing, and interacting with models. Users rely on curl commands and raw Prometheus metrics to understand gateway state, which is slow and error-prone — especially when managing multiple local and external workers across GPUs.

Solution

A full-featured terminal UI (smg-tui) built with Ratatui that connects to a running SMG gateway and provides real-time monitoring, worker management, and an interactive chat playground — all from the terminal.

TUI Demo

Changes

Views (7 tabs)

Tab View Description
1 Pulse Real-time dashboard — worker health grouped by model/provider, throughput sparkline (req/s), request stats (avg latency, connections, in-flight), GPU status
2 Workers Worker table with running reqs, KV cache token usage, per-worker req/s; detail panel with config, models, live stats, circuit breaker state
3 Chat Interactive streaming chat with markdown rendering, multi-turn support (chat completions: full history, responses API: previous_response_id)
4 Logs Sub-tab system (a:TUI, b:SMG gateway, c:per-worker logs labeled by model-port) with ANSI stripping
5-7 Benchmark, Traffic, Mesh Placeholders

Stats Bar

Four metric cards: Workers (count + health), Circuit Breakers (open/closed from Prometheus), REQ/S (from smg_router_requests_total + in-flight count), AVG LATENCY (from smg_router_request_duration with gauge bar)

Worker Management

Add Worker

  • External providers: Quick-add presets for OpenAI, Anthropic, xAI, Gemini with API key auto-read from env vars
  • Local workers: Launch sglang/vllm with model presets (Llama, Qwen, DeepSeek, Mistral), auto GPU selection via nvidia-smi with CUDA_VISIBLE_DEVICES, GPU claim tracking to prevent double-allocation
  • Lifecycle: Kills backend process + releases GPU claims on worker deletion

Gateway Auto-Start

--auto-start launches smg launch --enable-igw --policy round_robin, polls health endpoint, kills on exit

Chat Playground

  • Streaming SSE responses with live cursor
  • Markdown rendering (bold, italic, code blocks, headings, bullets)
  • Multi-turn: chat completions sends full history, responses API uses previous_response_id
  • Tab to cycle models, Shift+Tab to cycle endpoints
  • Handles both OpenAI and sglang streaming formats (delta events + response.completed)

Metrics Integration

Polls Prometheus for: req/s, avg latency, circuit breaker state, per-worker request counts, active connections, in-flight requests, token counts

Test Plan

  • cargo check -p smg-tui — compiles with zero warnings
  • Launch with --auto-start, verify gateway starts with IGW + round_robin
  • Add OpenAI worker via preset — verify 80+ models discovered (not wildcard)
  • Add local sglang workers (Llama-3.2-1B, Qwen2.5-7B) — verify auto GPU selection
  • Send 100 concurrent requests — verify req/s sparkline, avg latency, per-worker stats update
  • Chat with local model via /v1/chat and /v1/responses — verify streaming works
  • Delete worker — verify backend process killed and GPU released
  • Logs tab — verify TUI, gateway, and per-worker log sub-tabs

Summary by CodeRabbit

  • New Features

    • Interactive terminal dashboard: Pulse, Workers, Chat (streaming), Logs, Models, tabs, overlays, detailed worker views, real-time polling, sparklines, request/latency histories, worker add/update/delete, local model deployment with GPU selection/claiming, auto-start gateway, and graceful shutdown.
  • Documentation

    • Comprehensive TUI README with build/run steps, CLI flags (gateway/metrics/poll/api-key/auto-start), quick-start, multi-view guide, worker provisioning flows, and keybindings.
  • Chores

    • Added new TUI crate to the workspace and registered it as a workspace dependency.

@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Dependency updates labels Mar 22, 2026
@coderabbitai

coderabbitai Bot commented Mar 22, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a new terminal UI crate smg-tui to the workspace: a binary TUI client with CLI and optional auto-start gateway, a background poller, a typed SMG HTTP client, streaming chat support, local worker spawn/management, shared gateway state, and many UI modules and overlays.

Changes

Cohort / File(s) Summary
Workspace
Cargo.toml
Added tui to [workspace].members and registered smg-tui = { version = "0.1.0", path = "tui" } under [workspace.dependencies].
Package manifest & docs
tui/Cargo.toml, tui/README.md
New crate smg-tui (binary smg-tui) with dependencies (ratatui, crossterm, clap, tokio, reqwest, tracing, openai-protocol, etc.) and a comprehensive README describing build, CLI flags, auto-start behavior, views, keys, and workflows.
Entrypoint / CLI
tui/src/main.rs
CLI parsing, API-key resolution, optional gateway auto-start (spawn + readiness polling), terminal setup/restore, panic hook, poller spawn, and shutdown/cleanup logic.
Core app & event loop
tui/src/app.rs, tui/src/event.rs
App struct and async run loop, input/command routing, overlays and confirmations, chat streaming drain logic, in-memory logs, worker child tracking, local-worker spawn (GPU selection + process launch), and EventHandler for key/tick/resize events.
Gateway client
tui/src/client.rs
SmgClient typed HTTP client with short/streaming reqwest clients, bearer auth handling, methods for health/readiness, workers, loads, models, metrics, stream_request, and related endpoints.
Chat streaming
tui/src/chat.rs
Streaming support for /v1/chat/completions and /v1/responses with SSE-like parsing, token/response-id emission over mpsc channel, error/done sentinel handling, and multi-turn support.
Shared state & poller
tui/src/state.rs
GatewayState and SharedState alias; spawn_poller querying endpoints concurrently, Prometheus parsing, rolling histories (throughput/latency/tokens), per-worker RPS, and optional GPU discovery via nvidia-smi.
Types & exports
tui/src/types.rs, tui/src/lib.rs
View/InputMode enums, add-menu state machine, provider/runtime/model presets, action items; library root exports modules.
UI root & common
tui/src/ui/mod.rs, tui/src/ui/theme.rs, tui/src/ui/tabs.rs, tui/src/ui/footer.rs, tui/src/ui/help.rs, tui/src/ui/filter.rs
Main per-frame renderer, theme colors/styles, tab bar, footer hints/status, help overlay, and command/filter input rendering.
Overlays & dialogs
tui/src/ui/action_menu.rs, tui/src/ui/dialog.rs
Action menu and multi-step add-worker overlay renderers; delete and flush-cache confirmation dialogs.
Views & components
tui/src/ui/stats_bar.rs, tui/src/ui/pulse.rs, tui/src/ui/workers.rs, tui/src/ui/models.rs, tui/src/ui/chat.rs, tui/src/ui/logs.rs, tui/src/ui/detail.rs
Implemented stats bar, pulse view (health/GPU/throughput), workers table + detail pane, models table, chat view (markdown/code + streaming cursor), logs view (TUI/gateway/worker tabs), and worker detail panel.
UI helpers
tui/src/ui/sparkline.rs
Sparkline and gauge helpers used across views.
Pre-commit
.pre-commit-config.yaml
Extended codespell allowlist to include ratatui.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Main as main.rs
    participant App as app.rs
    participant EventHandler as event.rs
    participant SmgClient as client.rs
    participant Gateway as SMG Gateway

    User->>Main: run smg-tui
    Main->>SmgClient: new(gateway_url, metrics_url, api_key)
    Main->>Gateway: check_alive()
    alt gateway unreachable and --auto-start
        Main->>Main: spawn smg gateway process
        Main->>Gateway: poll readiness (timeout)
    end
    Main->>App: App::new(state, client)
    Main->>EventHandler: spawn event loop
    Main->>App: run()
    loop UI loop
        EventHandler->>App: AppEvent (Key/Tick/Resize)
        App->>SmgClient: HTTP commands / list / actions
        App->>App: render frame (ui/mod)
    end
    loop Polling task
        Poller->>SmgClient: list_workers, get_loads, metrics, models
        Poller->>SharedState: update caches and histories
    end
Loading
sequenceDiagram
    participant User
    participant App as app.rs
    participant Chat as chat.rs
    participant SmgClient as client.rs
    participant Gateway as /v1/chat or /v1/responses

    User->>App: Enter message
    App->>Chat: stream_chat(...)
    Chat->>SmgClient: stream_request(path, body)
    SmgClient->>Gateway: POST (stream:true)
    Gateway->>SmgClient: SSE stream (data: ...)
    SmgClient->>Chat: stream bytes
    Chat->>App: send tokens via mpsc channel
    App->>App: drain channel, append assistant text, render updates
    User->>App: Esc (cancel)
    App->>Chat: drop/stop streaming receiver
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested labels

model-gateway, protocols, realtime-api, openai, anthropic

Suggested reviewers

  • CatherineSue
  • slin1237

Poem

🐰
I hopped through code in twilight green,
A tiny terminal, cozy screen,
Streams of tokens softly flow,
Workers wake and metrics glow—
A rabbit cheers the dashboard scene.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 39.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding a terminal dashboard for SMG called 'tui'. It is specific, directly related to the changeset, and highlights the primary feature being introduced.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch keyang/tui-redesign

Comment @coderabbitai help to get the list of available commands and usage tips.

@mergify

mergify Bot commented Mar 22, 2026

Copy link
Copy Markdown
Contributor

Hi @key4ng, the DCO sign-off check has failed. All commits must include a Signed-off-by line.

To fix existing commits:

# Sign off the last N commits (replace N with the number of unsigned commits)
git rebase HEAD~N --signoff
git push --force-with-lease

To sign off future commits automatically:

  • Use git commit -s every time, or
  • VSCode: enable Git: Always Sign Off in Settings
  • PyCharm: enable Sign-off commit in the Commit tool window

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a comprehensive terminal UI for the Shepherd Model Gateway, addressing the need for a visual interface to monitor workers, manage routing, and interact with models. The smg-tui tool enhances user experience by providing real-time monitoring, worker management capabilities, and an interactive chat playground, all accessible from the terminal. It integrates with Prometheus for metrics and offers features like gateway auto-start and GPU management for local workers.

Highlights

  • Terminal UI for SMG: This PR introduces smg-tui, a terminal-based user interface for Shepherd Model Gateway (SMG), providing real-time monitoring, worker management, and an interactive chat playground.
  • Real-time Monitoring: The TUI offers a dashboard with worker health, throughput sparklines, request statistics (latency, connections), and GPU status, enabling users to quickly assess gateway performance.
  • Worker Management: Users can add and delete workers, with quick-add presets for external providers (OpenAI, Anthropic, etc.) and local workers (sglang/vllm), including automatic GPU selection and lifecycle management.
  • Interactive Chat Playground: The TUI includes a chat interface with streaming responses, markdown rendering, multi-turn support, and model/endpoint cycling, facilitating interactive testing and model exploration.
  • Logs and Metrics Integration: The TUI integrates logs from various sources (TUI, gateway, workers) and polls Prometheus for key metrics (req/s, latency, circuit breaker state, token counts), providing comprehensive insights into system behavior.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 263b44d602

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tui/src/app.rs Outdated
Comment thread tui/src/chat.rs Outdated
Comment thread tui/src/app.rs

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This is an impressive addition, introducing a full-featured terminal UI for SMG. The implementation is comprehensive, covering real-time monitoring, worker management, and an interactive chat playground. The code is well-structured, and features like gateway auto-start and automatic GPU management are excellent. My review focuses on improving robustness in a few key areas, such as metrics parsing and error handling, along with some minor performance and maintainability suggestions. Overall, this is a fantastic new capability for the project. I've added a suggestion to log errors instead of panicking in one instance.

Comment thread tui/src/state.rs
Comment thread tui/src/app.rs Outdated
Comment thread tui/src/app.rs
Comment thread tui/src/app.rs Outdated
Comment thread tui/src/chat.rs Outdated
Comment thread tui/src/client.rs Outdated
Comment thread tui/src/ui/logs.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 30

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tui/README.md`:
- Around line 66-70: The fenced code blocks showing the metrics snapshot and the
keybinding examples lack language tags and trigger MD040; update each of those
triple-backtick blocks (the metrics snapshot block and the other examples
referenced) to use the "text" language tag (i.e., change ``` to ```text) so
markdownlint stops flagging them; look for the blocks containing the
WORKERS/CIRCUIT BREAKERS table and the keybinding examples and add the "text"
tag to each fenced block.
- Around line 147-149: The README currently refers to both `/v1/chat` and
`/v1/chat/completions`; choose one canonical chat endpoint (either `/v1/chat` or
`/v1/chat/completions`) and make all references consistent: update the
"Multi-turn conversation" bullet and the TUI description so they use the same
endpoint string, and ensure any related examples, links, or headings (e.g.,
mentions of Chat completions or Responses API) are adjusted to match that
canonical endpoint; search for occurrences of `/v1/chat` and
`/v1/chat/completions` and replace them so only the chosen form remains.

In `@tui/src/app.rs`:
- Around line 1018-1031: spawn_local_worker currently binds a TcpListener to
"127.0.0.1:0" to read an ephemeral port then immediately drops it, causing a
TOCTOU race; change the implementation to avoid dropping the bound listener
before the worker is listening by either (1) passing port 0 to the worker
runtime (so the worker itself binds an ephemeral port and returns the actual
port), or (2) keep the TcpListener open and hand the listener or its bound
address to the spawned process (or retry binding when starting the worker) so
the port is not reclaimed between the bind and worker start; update
spawn_local_worker, the use of TcpListener and any logic around port and
set_status to implement one of these approaches.
- Around line 1071-1073: The code uses log_file.try_clone().unwrap() to create
log_file2 which can panic; replace the unwrap with explicit error handling
around try_clone() (e.g., match or Result propagation) so a failure to duplicate
the file is handled gracefully: call log_file.try_clone(), on Ok assign to
log_file2, on Err log an error via the same logger/UI and either fall back to a
single writer or return an Err from the enclosing function (propagate the
Result) so the TUI can display a friendly message instead of panicking; update
the function signature if needed to return Result and reference the try_clone
call and the log_file2 variable when making the change.
- Around line 745-760: The code uses Option::is_none_or on self.active_filter
(in the filter closure over wl.workers) which requires Rust 1.82+, but the
project targets 1.75. Replace the is_none_or usage with an equivalent map_or
call: on self.active_filter use map_or(true, |f| { ... }) so the closure becomes
self.active_filter.map_or(true, |f|
w.id.to_lowercase().contains(&f.to_lowercase()) ||
w.url.to_lowercase().contains(&f.to_lowercase())); keep the rest of the logic
that builds filtered, uses self.selected_index, and sets self.confirm_delete to
Some((worker.id.clone(), worker.url.clone())) unchanged.

In `@tui/src/chat.rs`:
- Around line 124-182: The final aggregated response is being sent even when
delta chunks were already emitted, causing duplicate output; add a flag (e.g.,
saw_deltas) scoped near the loop and set it to true inside the
"response.output_text.delta" branch, then in the "response.completed" |
"response.done" branch skip sending the full aggregated text if saw_deltas is
true (still send the "\n[DONE]" marker and return); update the handling around
parsed/event_type and tx sends (the buffer/stream loop, the
"response.output_text.delta" branch, and the
"response.completed"/"response.done" branch) to reference this flag.

In `@tui/src/client.rs`:
- Around line 13-14: Remove the incorrect #[allow(dead_code)] attribute applied
to the metrics_url field; metrics_url is actually used by fetch_metrics(), so
delete the #[allow(dead_code)] line above the metrics_url declaration to allow
the compiler to accurately reflect usage and avoid hiding potentially useful
warnings for that field.
- Around line 256-270: The current stream_request builds a new reqwest::Client
(stream_client) per call; instead add a dedicated streaming client field (e.g.,
streaming_client: reqwest::Client) initialized once in the client's
constructor/builder with the longer timeout (120s) and any TLS/config needed,
then change stream_request to use self.streaming_client.post(&url) instead of
creating a new client; alternatively, if you prefer not to add a field, use the
existing shared client and set a per-request timeout via request.timeout(...)
before send(). Ensure you preserve the bearer_auth logic (if let Some(key) =
&self.api_key) and keep error_for_status() handling.

In `@tui/src/event.rs`:
- Around line 41-45: The event handler currently forwards every Event::Key to tx
which can double-fire actions; guard the send with a check for key.kind ==
KeyEventKind::Press (import KeyEventKind from crossterm::event) and only call
tx.send(AppEvent::Key(key)) when the key event kind is Press, otherwise ignore
the event; keep the existing error/is_err() break behavior when sending fails.

In `@tui/src/main.rs`:
- Around line 156-169: The setup currently enables raw mode and switches to the
alternate screen before installing the panic hook, so if any early fallible call
(e.g., execute!(..., EnterAlternateScreen) or Terminal::new(...)) returns Err
via `?` the terminal is left mutated; wrap the terminal mutations in an RAII
guard struct (e.g., TerminalGuard) created immediately after enable_raw_mode()
that performs disable_raw_mode() and leaves the alternate screen in its Drop
impl and provide a disarm method to cancel cleanup on successful explicit
teardown; install the panic hook to call the guard's cleanup path (or rely on
Drop) and ensure creation of Terminal via Terminal::new(...) happens while the
guard is active so any error triggers Drop to restore the terminal state.

In `@tui/src/state.rs`:
- Around line 93-101: spawn_poller currently spawns a background task and drops
the JoinHandle, preventing graceful cancellation; change spawn_poller to return
the tokio::task::JoinHandle<()> (or a typed handle) instead of returning
nothing, create the handle with let handle = tokio::spawn(async move { ... });
return that handle, and ensure callers of spawn_poller capture the returned
JoinHandle and call abort().await or handle.await during shutdown; keep the
internal loop and poll_once usage unchanged (referencing spawn_poller and
poll_once) so callers can cancel the poller cleanly.
- Around line 394-407: The subtraction cur_sum - prev_sum can be negative after
a Prometheus counter reset; update the logic in the block using
parse_duration_stats and the s.prev_duration_sum / s.prev_duration_count guards
so delta_sum is clamped to non-negative (e.g., set delta_sum = if cur_sum >=
prev_sum { cur_sum - prev_sum } else { 0.0 }) before computing avg_latency and
pushing into s.avg_latency_history (respecting SPARKLINE_CAP); keep the existing
saturating_sub for delta_count and the conditional avg calculation.
- Around line 352-374: The throughput_history is being pushed twice per poll
(once with total_throughput and again with rps); change the logic so
throughput_history is only updated from rps (Prometheus) when metrics are
available and a prev_request_count exists, otherwise fall back to
total_throughput. Concretely, update the block that pushes total_throughput
(s.throughput_history.push_back(total_throughput)) to only execute when metrics
is Err or when parse_request_count/prev_request_count cannot produce rps, and
keep the push of rps (computed from parse_request_count and prev_request_count)
as the primary update; reference s.throughput_history, total_throughput, rps,
parse_request_count, and s.prev_request_count to locate and guard the duplicate
push.

In `@tui/src/types.rs`:
- Around line 16-69: The three View helpers (View::from_key, View::index,
View::all) are manually duplicated and can drift; refactor to a single
source-of-truth static ALL slice (e.g., pub const ALL: &[View]) and implement
from_key and index in terms of that slice (compute position for index and lookup
by digit-derived index in from_key) and have label either derived from a
parallel NAME slice or from a method on each variant but referenced via ALL;
update View::all to return that constant and add a small unit test that
validates ALL.len() and that index/from_key round-trip to catch future
mismatches.
- Around line 268-276: The env_key() method always returns Some(...) for every
variant (Self::OpenAI, Self::Anthropic, Self::Xai, Self::Gemini), so change its
signature from pub fn env_key(&self) -> Option<&'static str> to pub fn
env_key(&self) -> &'static str and update the match arms to return the string
literals directly (e.g., Self::OpenAI => "OPENAI_API_KEY"); then search for and
update all call sites that expect an Option (remove unwraps or Option handling)
to use the plain &str return.

In `@tui/src/ui/action_menu.rs`:
- Around line 136-149: The SelectModel menu currently leaks heap by calling
Box::leak for per-frame numbering strings when building items (see the items
vector creation and custom_num); update the code to stop leaking by passing
owned Strings (or Cow<'static, str>) into render_menu instead of &str, or keep
indices as numeric types and format them inside a stable, non-leaking buffer.
Concretely, change the items type from Vec<(&str, String, String)> and the refs
conversion to pass owned String/Cow (or Vec<(String, String, String)>) and
update render_menu signature to accept owned Strings/Cow<'static, str>, or
alternatively keep numbers as usize and format them on-demand in render_menu;
adjust calls in render_add_menu / render_menu and the title construction
(runtime.label()) accordingly so no Box::leak is used per frame.

In `@tui/src/ui/detail.rs`:
- Around line 121-127: The UI currently derives circuit breaker text/color from
worker.is_healthy (cb_label/cb_color), which is incorrect; change the code that
sets cb_label and cb_color to read the actual breaker state from the shared
breaker/metrics state instead of worker.is_healthy — e.g., call the service that
exposes breaker state (shared_metrics.get_breaker_state(worker.id) or
worker.breaker_state) and map that value to "open"/"closed" and
theme::RED/GREEN, then use those variables in the existing
lines.push(Line::from(...)) Span::styled calls so the detail panel reflects the
real circuit-breaker status.
- Around line 199-204: truncate_str currently slices bytes
(&s[..max.saturating_sub(1)]) which panics on multi-byte UTF-8 boundaries;
change it to operate on chars: check s.chars().count() <= max and return
s.to_string() otherwise take max.saturating_sub(1) chars via
s.chars().take(...).collect::<String>() and append the ellipsis; also handle the
edge case when max == 0 (return empty string or just the ellipsis per desired
behaviour). Use the function name truncate_str to locate and replace the
byte-slicing logic with char-based truncation.

In `@tui/src/ui/dialog.rs`:
- Around line 11-50: The popup centering/Layout code is duplicated between
render_delete_dialog and render_flush_dialog; extract a small helper (e.g., fn
centered_popup(area: Rect, width: u16, height: u16) -> Rect or fn popup_rect(f:
&mut Frame, width: u16, height: u16) -> Rect) that encapsulates the
Layout::vertical and Layout::horizontal + Constraint::Length/Flex::Center logic
used to compute popup, then replace the duplicated blocks in
render_delete_dialog and render_flush_dialog to call that helper with the
desired width/height and use its return value when rendering the Clear and
Paragraph widgets.
- Around line 40-43: The code uses tuple indexing (info.0, info.1) when building
the confirmation text in dialog.rs (used in confirm_delete and confirm_flush),
making it hard to read; replace tuple access with either a small named struct
(e.g., WorkerInfo { id, url }) or destructure the tuple at the call site (let
(id, url) = info) and then use id and url in the format! call; update the
related occurrences around confirm_delete and confirm_flush to use the new names
for clarity and consistency.

In `@tui/src/ui/footer.rs`:
- Around line 25-37: Update the footer view-range hint from "1-5" to "1-7" so
the footer advertises all seven views; locate the two occurrences of hint("1-5",
"view") in the footer construction (the match arms that build the Vec of hints
using the hint(...) calls) and change them to hint("1-7", "view") to match the
README and the newly added Traffic and Mesh tabs.

In `@tui/src/ui/help.rs`:
- Around line 36-44: The duplicated "Navigation" header in the help text (inside
the match arm that calls text.push_str with the block starting "Navigation\n  j
/ Down...") should be renamed to something like "Selection" or "List Navigation"
to avoid confusion with the earlier view-switching "Navigation" section; locate
the text.push_str call in help.rs that contains the "Navigation" literal and
update that header string to "Selection" (or "List Navigation") while preserving
the rest of the formatting and shortcuts.

In `@tui/src/ui/logs.rs`:
- Around line 170-197: render_file_log currently does blocking file I/O
(std::fs::read_to_string and content.lines()) on the render path which stalls
the UI; move all file reading and line-walking into a background task that tails
or caches the last N lines and expose a non-blocking accessor on App (e.g.,
App::cached_log(path) or a LogsCache component) so render_file_log only reads
that cached slice and renders it; ensure the background poll updates the cache
atomically and handles file-not-found/errors so render_file_log can simply
render a precomputed Vec<Line> or a fallback message without performing any disk
I/O.

In `@tui/src/ui/models.rs`:
- Line 16: The table header "OWNER" in the Row::new(...) call does not match the
value being rendered (m.display_name); update the header in Row::new(vec!["ID",
"OWNER", "WORKERS", "CREATED"]) to reflect the actual displayed field (e.g.,
"DISPLAY NAME") or instead render the true owner field (replace m.display_name
with m.owner or the correct owner accessor) so the column label and value align;
adjust the header or the cell rendering in the models table accordingly.

In `@tui/src/ui/pulse.rs`:
- Around line 20-37: The narrow-layout branch currently returns before calling
render_request_stats, so add the request-stats panel to the vertical layout:
extend the constraints Vec (used with Layout::vertical) to include an extra
Constraint::Fill(1) for request stats, adjust the subsequent pushes/order to
keep Worker Health, optional Node Status (has_node_panel), Throughput, and
Request Stats, then after splitting into rows call render_worker_health(...),
optionally render_node_status(...), render_throughput_compact(...), and finally
render_request_stats(f, &state, rows[i]) (incrementing i as you go) before
returning; update the rows indexing to match the added constraint.

In `@tui/src/ui/stats_bar.rs`:
- Around line 82-89: The health_text logic incorrectly shows "all healthy" when
total == 0; update the branch in the health_text computation (referencing total,
healthy, unhealthy, state.connected, and health_text) to first handle total == 0
(and when connected) by returning a neutral label like "no workers" or "--" with
theme::TEXT_MUTED, then proceed to the existing unhealthy == 0 => "all healthy"
(theme::GREEN) and else => "{unhealthy} unhealthy" (theme::RED); ensure the new
check is before the unhealthy == 0 branch so empty gateways are not reported as
all healthy.

In `@tui/src/ui/tabs.rs`:
- Around line 14-32: The tab numbering uses the iterator index i + 1 but should
use the View-provided 1-based index for consistency; replace the use of i + 1
when building the num string with view.index() in the tabs construction (the
block that builds tabs: Vec<Span> in tabs.rs), i.e., change the num assignment
to use view.index() so Span::styled(format!("{num}:{label}"), ...) uses the
authoritative View::index() value.

In `@tui/src/ui/workers.rs`:
- Around line 321-341: get_worker_load_info currently only reads
details.loads.first(), dropping additional TP>1 entries and under-reporting
running/token usage, and also treats plain http:// workers as 0 when /get_loads
is missing; update get_worker_load_info to aggregate across all entries in
wl.details.loads (summing num_running_reqs and computing a weighted or averaged
token_usage consistent with the roll-up in state.rs), return the aggregated
running and usage strings, and if no loads are present fall back to per-worker
Prometheus rps from worker_rps for any non-load-backed URL (use
worker_url.starts_with checks as currently implemented for grpc/https but
include plain http:// fallback to worker_rps instead of returning "0"). Ensure
you update references to wl.details, details.loads, worker_rps, and worker_url
in get_worker_load_info so all load entries are summed rather than only using
the first.
- Around line 264-277: The table selection is clamped when building table_state
but the detail pane still uses the raw app.selected_index; compute a single
clamped index once (e.g., let clamped = if row_count==0 { None } else {
Some(app.selected_index.min(row_count.saturating_sub(1))) }) and use that both
for table_state.select(...) and for indexing into filtered before calling
detail::render_detail so the detail pane always matches the highlighted row and
avoids out-of-bounds access.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4cdb3e65-d847-4094-a825-23b5d636e80a

📥 Commits

Reviewing files that changed from the base of the PR and between c899736 and 263b44d.

⛔ Files ignored due to path filters (2)
  • tui/assets/add-worker.gif is excluded by !**/*.gif
  • tui/assets/tui-demo.gif is excluded by !**/*.gif
📒 Files selected for processing (27)
  • Cargo.toml
  • tui/Cargo.toml
  • tui/README.md
  • tui/src/app.rs
  • tui/src/chat.rs
  • tui/src/client.rs
  • tui/src/event.rs
  • tui/src/lib.rs
  • tui/src/main.rs
  • tui/src/state.rs
  • tui/src/types.rs
  • tui/src/ui/action_menu.rs
  • tui/src/ui/chat.rs
  • tui/src/ui/detail.rs
  • tui/src/ui/dialog.rs
  • tui/src/ui/filter.rs
  • tui/src/ui/footer.rs
  • tui/src/ui/help.rs
  • tui/src/ui/logs.rs
  • tui/src/ui/mod.rs
  • tui/src/ui/models.rs
  • tui/src/ui/pulse.rs
  • tui/src/ui/sparkline.rs
  • tui/src/ui/stats_bar.rs
  • tui/src/ui/tabs.rs
  • tui/src/ui/theme.rs
  • tui/src/ui/workers.rs

Comment thread tui/README.md Outdated
Comment thread tui/README.md
Comment thread tui/src/app.rs
Comment thread tui/src/app.rs
Comment thread tui/src/app.rs Outdated
Comment thread tui/src/ui/pulse.rs
Comment thread tui/src/ui/stats_bar.rs
Comment thread tui/src/ui/tabs.rs
Comment thread tui/src/ui/workers.rs
Comment thread tui/src/ui/workers.rs Outdated
@key4ng
key4ng force-pushed the keyang/tui-redesign branch from 263b44d to 50b7078 Compare March 22, 2026 20:00

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 50b7078996

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tui/src/main.rs Outdated
Comment thread tui/src/ui/action_menu.rs Outdated
Comment thread tui/src/state.rs
@key4ng
key4ng force-pushed the keyang/tui-redesign branch from 50b7078 to 95a1ee0 Compare March 22, 2026 20:11

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

♻️ Duplicate comments (23)
tui/src/client.rs (1)

264-266: 🧹 Nitpick | 🔵 Trivial

Avoid creating a new reqwest::Client per streaming request.

Each call to stream_request builds a new reqwest::Client, losing connection pooling benefits and TLS session cache. Consider initializing a dedicated streaming client with the longer timeout in the constructor.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/client.rs` around lines 264 - 266, The streaming code builds a new
reqwest::Client inside stream_request which prevents connection pooling and TLS
session reuse; instead add a dedicated streaming client field (e.g.,
stream_client: reqwest::Client) to the client struct initialized in the
constructor using Client::builder().timeout(Duration::from_secs(120)). Replace
the local builder call in stream_request with a reuse of self.stream_client so
all streaming requests reuse the same client and timeout configuration.
tui/src/state.rs (3)

395-398: ⚠️ Potential issue | 🟠 Major

Bug: throughput_history receives duplicate entries per poll cycle.

When both worker metrics are available (line 398) AND Prometheus metrics succeed with a previous count (line 427), throughput_history gets pushed twice per cycle. This corrupts sparkline data.

Based on the comment at line 423, rps should be the primary source. Consider removing the push at line 398 or making them mutually exclusive.

Also applies to: 424-427

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/state.rs` around lines 395 - 398, throughput_history is being
appended twice per poll cycle because total_throughput is pushed unconditionally
and later rps (from Prometheus) can also push; update the logic so the sparkline
gets a single source per cycle: prefer rps when available by making the first
push conditional (e.g., only push total_throughput into s.throughput_history
when rps is not available) or convert the two pushes into mutually exclusive
branches; touch the block that uses SPARKLINE_CAP and total_throughput as well
as the later branch that pushes rps to ensure only one push to
s.throughput_history happens per cycle.

95-105: 🧹 Nitpick | 🔵 Trivial

Consider returning JoinHandle for graceful shutdown.

spawn_poller discards the JoinHandle, preventing the caller from canceling the poller during application shutdown.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/state.rs` around lines 95 - 105, spawn_poller currently drops the
tokio::task::JoinHandle which prevents graceful shutdown; change spawn_poller to
return the JoinHandle (tokio::task::JoinHandle<()>) instead of () by capturing
the result of tokio::spawn and returning it, update callers to hold and
abort/await the handle during shutdown, and ensure the signature in state.rs
(spawn_poller) and any call sites are updated to propagate or store the returned
JoinHandle so the poller can be cancelled or awaited cleanly.

451-466: ⚠️ Potential issue | 🟡 Minor

Potential negative latency on Prometheus counter reset.

Line 453 uses plain subtraction (cur_sum - prev_sum) which can yield a negative value if Prometheus restarts and counters reset. This would push negative latency values to avg_latency_history.

Proposed fix
         let (cur_sum, cur_count) = parse_duration_stats(&metrics_text);
         if let (Some(prev_sum), Some(prev_count)) = (s.prev_duration_sum, s.prev_duration_count) {
-            let delta_sum = cur_sum - prev_sum;
+            let delta_sum = if cur_sum >= prev_sum { cur_sum - prev_sum } else { 0.0 };
             let delta_count = cur_count.saturating_sub(prev_count);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/state.rs` around lines 451 - 466, The subtraction cur_sum - prev_sum
in the parse_duration_stats handling can produce negative delta_sum when
Prometheus resets counters; update the logic in the block around
parse_duration_stats / s.prev_duration_sum / s.prev_duration_count so you
compute delta_sum = (cur_sum as f64 - prev_sum as f64) and then guard: if
delta_sum > 0.0 && delta_count > 0 { avg_latency = delta_sum / delta_count as
f64; push to s.avg_latency_history (evict oldest when >= SPARKLINE_CAP) } else
skip pushing (or treat avg_latency as 0.0 but do not push negative values).
Ensure prev_duration_sum and prev_duration_count are still updated after the
check.
tui/src/ui/tabs.rs (1)

14-32: 🧹 Nitpick | 🔵 Trivial

Use view.index() instead of i + 1 for consistency.

Line 18 calculates the tab number as i + 1, but if View has an index() method that returns the 1-based index, using it would ensure consistency if the view order or numbering ever changes.

Proposed fix
     let tabs: Vec<Span> = View::all()
         .iter()
-        .enumerate()
-        .flat_map(|(i, view)| {
-            let num = format!("{}", i + 1);
+        .flat_map(|view| {
+            let num = format!("{}", view.index());
             let label = view.label();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/tabs.rs` around lines 14 - 32, Replace the manual index
calculation i + 1 with the view-provided index to keep numbering consistent:
when building the tab Spans in the iterator over View::all(), call view.index()
(instead of using the enumerated i) to produce the tab number string used in
format!("{num}:{label}") — keep using view.label() for the label and the
existing style/active comparison with active to preserve behavior.
tui/src/ui/models.rs (1)

18-18: ⚠️ Potential issue | 🟡 Minor

Align the column header with rendered data.

Line 18 says OWNER, but Line 51 renders m.display_name. Rename the header (or render owner) so the column meaning is accurate.

Minimal fix
-    let header = Row::new(vec!["ID", "OWNER", "WORKERS", "CREATED"]).style(
+    let header = Row::new(vec!["ID", "DISPLAY NAME", "WORKERS", "CREATED"]).style(

Also applies to: 49-52

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/models.rs` at line 18, The header Row::new currently labels the
column "OWNER" but the rendered cell uses m.display_name, causing a mismatch;
update the header text in the Row::new call (the header variable) to match the
rendered field (e.g., "DISPLAY NAME" or "NAME"), or alternatively change the
rendering where m.display_name is used to render an actual owner field; locate
the Row::new(...) that defines header and the rendering loop that uses
m.display_name and make the header and rendered field names consistent.
tui/README.md (2)

66-70: ⚠️ Potential issue | 🟡 Minor

Add language identifiers to fenced code blocks.

Lines 66, 168, and 223 use unlabeled fenced blocks and trigger MD040. Use text for these examples.

Proposed fix
-```
+```text
     WORKERS          CIRCUIT BREAKERS          REQ/S            AVG LATENCY
        5                    5                  250.3              450ms
    all healthy          all closed          51 in-flight        ▓▓▓░░░░░

@@
- +text
a:TUI b:SMG c:Llama-37595 c:Qwen2-39223 j/k scroll G bottom c cycle

@@
-```
+```text
:add <url> [--provider <p>] [--runtime <r>]
:delete <id>
:priority <number>
:cost <number>
:flush-cache
:toggle-health
:quit
</details>


Also applies to: 168-170, 223-231

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @tui/README.md around lines 66 - 70, The three unlabeled fenced code blocks
in tui/README.md (the ASCII status table starting with "WORKERS CIRCUIT
BREAKERS", the key legend line starting with "a:TUI b:SMG c:Llama-37595", and
the command list starting with ":add ") are triggering MD040; add the
language identifier text to each opening triple-backtick fence (i.e., change totext for the blocks containing those exact snippets) so the fenced blocks
are properly labeled.


</details>

---

`148-149`: _⚠️ Potential issue_ | _🟡 Minor_

**Use one canonical chat endpoint across the README.**

Line 148 documents `/v1/chat`, while Line 273 documents `/v1/chat/completions`. Keep these consistent to avoid copy/paste misconfiguration.
 


Also applies to: 273-273

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tui/README.md` around lines 148 - 149, Choose a single canonical chat
endpoint and make all README occurrences consistent: replace
`/v1/chat/completions` with `/v1/chat` (or vice versa) throughout the file, and
update any example request/response blocks, headings, and references that
mention `/v1/chat` or `/v1/chat/completions` so they match; also ensure the
section that contrasts chat with the Responses API still references
`previous_response_id` and `/v1/responses` correctly.
```

</details>

</blockquote></details>
<details>
<summary>tui/src/ui/workers.rs (2)</summary><blockquote>

`269-283`: _⚠️ Potential issue_ | _🟠 Major_

**Use one clamped selection for both table highlight and detail pane.**

Line 272 clamps selection for the table, but Line 281 still indexes with raw `app.selected_index`. After filtering/deletion, highlighted row and detail panel can diverge.
 
<details>
<summary>Proposed fix</summary>

```diff
-    let mut table_state = TableState::default();
-    if row_count > 0 {
-        table_state.select(Some(app.selected_index.min(row_count.saturating_sub(1))));
-    }
+    let selected = if row_count > 0 {
+        Some(app.selected_index.min(row_count.saturating_sub(1)))
+    } else {
+        None
+    };
+    let mut table_state = TableState::default();
+    table_state.select(selected);
@@
-    if let Some(detail_area) = detail_area {
-        if let Some(worker) = filtered.get(app.selected_index) {
+    if let Some(detail_area) = detail_area {
+        if let Some(worker) = selected.and_then(|idx| filtered.get(idx)) {
             detail::render_detail(f, app, worker, detail_area);
         }
     }
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/workers.rs` around lines 269 - 283, Compute a single clamped
selection index and use it for both the table selection and detail lookup
instead of using app.selected_index twice; e.g., create a local let selected =
if row_count > 0 { app.selected_index.min(row_count.saturating_sub(1)) } else {
0 }; call table_state.select(Some(selected)) and then use filtered.get(selected)
when calling detail::render_detail so the highlighted row and detail pane always
refer to the same clamped index.
```

</details>

---

`321-347`: _⚠️ Potential issue_ | _🟠 Major_

**Aggregate all load shards and broaden RPS fallback.**

Line 329 only reads `details.loads.first()`, which under-reports TP>1 workers. Also, Line 337/342 fallback to `worker_rps` only for `grpc://` and `https://`; plain `http://` workers can incorrectly show `0`.

</blockquote></details>
<details>
<summary>tui/src/ui/dialog.rs (2)</summary><blockquote>

`40-43`: _🧹 Nitpick_ | _🔵 Trivial_

**Replace tuple indexing with destructuring for dialog text.**

Line 42 and Line 83 use `info.0/info.1`, which is harder to read in formatted prompts. Destructure once into named locals before `format!`.
 
<details>
<summary>Readability improvement</summary>

```diff
-    let text = format!(
+    let (id, url) = info;
+    let text = format!(
         "Delete worker?\n\nID:  {}\nURL: {}\n\n[y] confirm  [n/Esc] cancel",
-        info.0, info.1,
+        id, url,
     );
```
</details>


Also applies to: 81-84

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/dialog.rs` around lines 40 - 43, Destructure the tuple `info` into
named locals (e.g., `let (id, url) = info;`) before building the dialog text and
anywhere else `info.0` / `info.1` are used (the `format!` call that produces the
"Delete worker?" text and the other dialog usage later), then replace `info.0`
and `info.1` with the new `id` and `url` identifiers to improve readability in
functions handling the dialog text (where `info` is referenced).
```

</details>

---

`19-30`: _🧹 Nitpick_ | _🔵 Trivial_

**Extract shared popup-rect logic to a helper.**

Line 19 and Line 60 duplicate the same centering/layout code. A small helper will keep both dialogs consistent and reduce drift when popup sizing changes.
 
<details>
<summary>Refactor sketch</summary>

```diff
+fn centered_popup(area: ratatui::layout::Rect, width: u16, height: u16) -> ratatui::layout::Rect {
+    let [_, vert, _] = Layout::vertical([
+        Constraint::Fill(1),
+        Constraint::Length(height),
+        Constraint::Fill(1),
+    ]).areas(area);
+    let [popup] = Layout::horizontal([Constraint::Length(width)])
+        .flex(Flex::Center)
+        .areas(vert);
+    popup
+}
@@
-    let area = f.area();
-    let [_, vert, _] = Layout::vertical([...]).areas(area);
-    let [popup] = Layout::horizontal([Constraint::Length(50)]).flex(Flex::Center).areas(vert);
+    let popup = centered_popup(f.area(), 50, 8);
```
</details>


Also applies to: 60-71

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/dialog.rs` around lines 19 - 30, Extract the duplicated
centering/layout code into a small helper (e.g., fn centered_popup(area: Rect,
width: u16, height: u16) -> Rect) that performs the Layout::vertical/Horizontal
sequence using Constraint::Length and Flex::Center and returns the resulting
popup rect; replace both occurrences that currently bind [_, vert, _] and
[popup] with a call to centered_popup(area, 50, 8) (or the appropriate
width/height) so the dialog drawing code uses the single helper instead of
duplicating the Layout logic. Ensure the helper references Layout, Constraint,
and Flex and accepts the input `area` so both existing call sites (the blocks
creating `vert`/`popup`) can be swapped to the new function.
```

</details>

</blockquote></details>
<details>
<summary>tui/src/ui/help.rs (1)</summary><blockquote>

`40-48`: _⚠️ Potential issue_ | _🟡 Minor_

**Rename the second “Navigation” section to avoid ambiguity.**

Line 43 repeats the same section header used at Line 14, but this block is specifically for list movement. Rename it to “Selection” (or similar) so users can distinguish view switching from item navigation.
 
<details>
<summary>Proposed wording change</summary>

```diff
-Navigation
+Selection
   j / Down     Move selection down
   k / Up       Move selection up
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/help.rs` around lines 40 - 48, Update the duplicate section header
inside the string passed to text.push_str so the second "Navigation" block is
renamed to "Selection" (or similar) to clarify it refers to list/item movement
rather than view switching; locate the string literal in tui/src/ui/help.rs
where text.push_str(...) is called and change the header line "Navigation" to
"Selection" while leaving the rest of the key descriptions unchanged.
```

</details>

</blockquote></details>
<details>
<summary>tui/src/ui/logs.rs (1)</summary><blockquote>

`163-207`: _⚠️ Potential issue_ | _🟠 Major_

**Move log file reading out of the render path.**

Line 164 performs blocking disk I/O, and Lines 187-190 walk the full file on each frame. This will cause visible UI stalls as logs grow. Render should read from an already-cached tail maintained by background polling.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/logs.rs` around lines 163 - 207, render_file_log currently does
blocking disk I/O and recomputes the file tail every frame; move file reading
and line processing into a background poller that maintains a cached tail on the
App and make render_file_log read only that cached data. Implement a background
task (spawned at app init) that reads the log file path, trims to max_lines
(500), strips ANSI and maps to styled Line objects once, and stores the result
in a thread-safe field on App (e.g., Arc<RwLock<Vec<Line>>> or similar); update
the poller at an interval (e.g., 250–500ms) and handle file-not-found there.
Change render_file_log to fetch the precomputed Vec<Line> from App and render it
without doing any file I/O or heavy processing (leave strip_ansi only in the
poller), and keep the existing formatting logic but applied only in the
background updater so rendering is non-blocking.
```

</details>

</blockquote></details>
<details>
<summary>tui/src/ui/footer.rs (1)</summary><blockquote>

`25-37`: _⚠️ Potential issue_ | _🟡 Minor_

**Update the footer hint to advertise all seven views.**

Lines 25 and 37 show `hint("1-5", "view")`, but this PR adds seven tabs (Pulse, Workers, Chat, Logs, Benchmark, Traffic, Mesh) and the README documents `1-7`. Users won't know `6` and `7` are available.




<details>
<summary>🩹 Proposed fix</summary>

```diff
-                    hint("1-5", "view"),
+                    hint("1-7", "view"),
...
-                    hint("1-5", "view"),
+                    hint("1-7", "view"),
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/footer.rs` around lines 25 - 37, The footer currently advertises
only "1-5" views in the hint(...) calls; update both occurrences of hint("1-5",
"view") in the footer generation (the branch bodies shown around the vec![...]
blocks in tui::ui::footer.rs) to advertise "1-7" so the footer reflects the
seven tabs (Pulse, Workers, Chat, Logs, Benchmark, Traffic, Mesh) added by this
PR.
```

</details>

</blockquote></details>
<details>
<summary>tui/src/ui/stats_bar.rs (1)</summary><blockquote>

`78-85`: _⚠️ Potential issue_ | _🟡 Minor_

**Handle the zero-worker case explicitly.**

When `total == 0` and `state.connected`, Lines 81-82 still evaluate `unhealthy == 0` as true and render `"all healthy"`, which is misleading for an empty gateway.




<details>
<summary>🩹 Proposed fix</summary>

```diff
     let health_text = if !state.connected {
         ("--".to_string(), theme::TEXT_MUTED)
+    } else if total == 0 {
+        ("no workers".to_string(), theme::TEXT_MUTED)
     } else if unhealthy == 0 {
         ("all healthy".to_string(), theme::GREEN)
     } else {
         (format!("{unhealthy} unhealthy"), theme::RED)
     };
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/stats_bar.rs` around lines 78 - 85, The health-text logic
incorrectly shows "all healthy" when total == 0; update the health_text
assignment in stats_bar.rs to explicitly handle the zero-worker case before
checking unhealthy: when state.connected is true and total == 0 return a clear
indicator (e.g., "--" or "no workers", with theme::TEXT_MUTED) instead of "all
healthy"; keep the existing branches for state.connected == false, unhealthy ==
0, and unhealthy > 0, referencing the variables unhealthy, total, healthy and
state.connected so the check order is total == 0 first.
```

</details>

</blockquote></details>
<details>
<summary>tui/src/event.rs (1)</summary><blockquote>

`46-53`: _⚠️ Potential issue_ | _🟠 Major_

**Filter to accept only `KeyEventKind::Press` key events.**

The current code forwards all `Event::Key` events including `KeyEventKind::Repeat` and `KeyEventKind::Release` on terminals that emit them (notably Windows). This can cause actions to double-fire.




<details>
<summary>🩹 Proposed fix</summary>

```diff
-use crossterm::event::{Event, EventStream, KeyEvent};
+use crossterm::event::{Event, EventStream, KeyEvent, KeyEventKind};
...
-                            Some(Ok(Event::Key(key)))
-                                if tx.send(AppEvent::Key(key)).is_err() => {
-                                    break;
-                                }
+                            Some(Ok(Event::Key(key)))
+                                if key.kind == KeyEventKind::Press =>
+                            {
+                                if tx.send(AppEvent::Key(key)).is_err() {
+                                    break;
+                                }
+                            }
+                            Some(Ok(Event::Key(_))) => {}
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tui/src/event.rs` around lines 46 - 53, The Event::Key branch currently
forwards all key events; change the match arm handling Some(Ok(Event::Key(key)))
so it only sends AppEvent::Key when key.kind == KeyEventKind::Press (ignore
KeyEventKind::Repeat and KeyEventKind::Release) — update the pattern/if guard
around the Event::Key arm and ensure KeyEventKind is referenced/imported so
tx.send(AppEvent::Key(key)) is only called for KeyEventKind::Press.
```

</details>

</blockquote></details>
<details>
<summary>tui/src/ui/detail.rs (2)</summary><blockquote>

`225-231`: _⚠️ Potential issue_ | _🔴 Critical_

**Fix byte-slicing vulnerability in `truncate_str` to prevent panics on non-ASCII worker IDs.**

Line 229 uses `&s[..max.saturating_sub(1)]` which panics if the byte index falls on a non-UTF-8 character boundary. This crashes the TUI when truncating worker IDs containing multi-byte characters.




<details>
<summary>🩹 Proposed fix</summary>

```diff
 fn truncate_str(s: &str, max: usize) -> String {
-    if s.len() <= max {
+    if s.chars().count() <= max {
         s.to_string()
     } else {
-        format!("{}…", &s[..max.saturating_sub(1)])
+        let prefix: String = s.chars().take(max.saturating_sub(1)).collect();
+        format!("{prefix}…")
     }
 }
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/detail.rs` around lines 225 - 231, The truncate_str function
slices by byte index (&s[..max.saturating_sub(1)]) which can panic on multi-byte
UTF-8 characters; change truncate_str to perform a character-aware truncate
(e.g., iterate s.chars().take(...) or use char_indices to find a valid byte
boundary) so you build a new String of at most max-1 characters and then append
the ellipsis, ensuring you handle the case s.len() <= max by returning
s.to_string(); update the function truncate_str accordingly to avoid direct
byte-slicing.
```

</details>

---

`130-143`: _⚠️ Potential issue_ | _🟠 Major_

**Don't derive circuit-breaker state from worker health.**

Lines 131-136 map `worker.is_healthy` to `closed/open`, but these are different signals. A worker can remain healthy (passing health checks) while its circuit breaker is open (tripped due to errors). The detail panel will misreport the actual breaker state.

Pull the breaker state from shared metrics/state instead of proxying through health.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/detail.rs` around lines 130 - 143, The circuit display currently
derives cb_label and cb_color from worker.is_healthy which is incorrect; instead
query the shared breaker state/store for this worker (e.g., call the circuit
state accessor for the worker id or use worker.circuit_state /
worker.metrics.circuit_open if available) and base cb_label ("open"/"closed")
and cb_color (theme::RED/theme::GREEN) on that boolean. Update the code that
builds the "Circuit:" Line (the Span/Style creation around cb_label/cb_color and
the variables cb_label/cb_color) to use the true circuit boolean from the shared
metrics/state rather than worker.is_healthy. Ensure the accessor you use is
thread-safe and present in scope before replacing references.
```

</details>

</blockquote></details>
<details>
<summary>tui/src/chat.rs (1)</summary><blockquote>

`163-182`: _⚠️ Potential issue_ | _🟠 Major_

**Avoid duplicating the final response on `response.completed` / `response.done`.**

If the stream already emitted `response.output_text.delta` chunks (Lines 163-167), Lines 168-182 append the full completed text again. This duplicates assistant output for backends that send both deltas and a final aggregated response.




<details>
<summary>🩹 Proposed fix</summary>

```diff
     let mut stream = resp.bytes_stream();
     let mut buffer = String::new();
+    let mut saw_text_delta = false;
...
                         "response.output_text.delta" => {
                             if let Some(delta) = parsed["delta"].as_str() {
+                                saw_text_delta = true;
                                 let _ = tx.send(delta.to_string());
                             }
                         }
                         "response.completed" | "response.done" => {
-                            // Extract text from completed response (sglang sends full text here, not deltas)
-                            if let Some(outputs) = parsed["response"]["output"].as_array() {
+                            // Only extract if no deltas were received (some backends send full text here)
+                            if !saw_text_delta {
+                                if let Some(outputs) = parsed["response"]["output"].as_array() {
...
+                                }
+                            }
                             let _ = tx.send("\n[DONE]".to_string());
                             return;
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tui/src/chat.rs` around lines 163 - 182, The completed/done branch duplicates
assistant output when prior "response.output_text.delta" chunks were already
sent; introduce a boolean flag (e.g., has_sent_deltas or saw_delta) in the
surrounding scope, set it to true inside the "response.output_text.delta" arm
where tx.send(delta.to_string()) is called, and in the "response.completed" |
"response.done" arm skip sending the full aggregated text (and only send the
final "[DONE]" marker) if that flag is true; ensure the flag is referenced where
tx.send is used so only one representation of the assistant response is emitted.
```

</details>

</blockquote></details>
<details>
<summary>tui/src/ui/action_menu.rs (1)</summary><blockquote>

`145-167`: _⚠️ Potential issue_ | _🟠 Major_

**Remove the per-frame `Box::leak` allocations in SelectModel menu rendering.**

Lines 151 and 155 leak numbering strings every frame the model picker is displayed. Since `render_add_menu` is called from the per-frame root renderer, keeping the SelectModel modal open causes unbounded heap growth.




<details>
<summary>🩹 Proposed fix</summary>

Change `render_menu` to accept owned `String` items:

```diff
-fn render_menu(f: &mut Frame, title: &str, items: &[(&str, &str, &str)]) {
+fn render_menu(f: &mut Frame, title: &str, items: &[(String, String, String)]) {
...
-            Span::styled(
-                format!(" [{num}] "),
+            Span::styled(
+                format!(" [{}] ", num),
...
-            Span::styled(*label, Style::default().fg(theme::TEXT)),
+            Span::styled(label.as_str(), Style::default().fg(theme::TEXT)),

// In SelectModel case:
-                let num = Box::leak(format!("{}", i + 1).into_boxed_str()) as &str;
-                (num, p.label(), format!("TP={}", p.tp()))
+                ((i + 1).to_string(), p.label(), format!("TP={}", p.tp()))
...
-            let custom_num = Box::leak(format!("{}", presets.len() + 1).into_boxed_str()) as &str;
+            let custom_num = (presets.len() + 1).to_string();
```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/action_menu.rs` around lines 145 - 167, The SelectModel branch is
leaking numeric labels via Box::leak each frame; stop allocating leaked &'static
str by switching to owned strings and updating the menu renderer. Build items as
Vec<(String, String, String)> (create the numeric label with format! without
Box::leak) in the AddMenuState::SelectModel arm, then change render_menu to
accept owned String tuples (or a slice of (String,String,String)) instead of
&str references and update any call sites accordingly so you can pass the owned
items directly without leaking memory.
```

</details>

</blockquote></details>
<details>
<summary>tui/src/ui/pulse.rs (1)</summary><blockquote>

`21-38`: _⚠️ Potential issue_ | _🟠 Major_

**Keep request stats in the narrow layout.**

This branch still returns after `render_throughput_compact()`, so small terminals never render latency, connections, or in-flight request stats. The compact vertical layout needs an extra row for `render_request_stats()`.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/pulse.rs` around lines 21 - 38, The narrow-layout branch returns
too early and omits request stats; add another row to the constraints Vec
(another Constraint::Fill(1)) when building the single-column layout and call
render_request_stats(f, &state, rows[i]) in the sequence before the final
render_throughput_compact (adjusting the index increments to account for the
extra row and respecting has_node_panel), so Layout::vertical(...).split(area)
produces an extra rows entry and render_request_stats is invoked for small
terminals.
```

</details>

</blockquote></details>
<details>
<summary>tui/src/main.rs (1)</summary><blockquote>

`158-170`: _⚠️ Potential issue_ | _🟠 Major_

**Protect terminal setup with a guard, not only the panic hook.**

Any early `?` after raw mode / alternate-screen setup but before normal teardown will exit without restoring the shell state. Keep a cleanup guard alive from the first successful terminal mutation and disarm it only after explicit teardown succeeds.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
Verify each finding against the current code and only fix it if needed.

In `@tui/src/main.rs` around lines 158 - 170, The terminal setup currently relies
only on a panic hook and can leak terminal state if an early `?` returns; create
a RAII guard (e.g., a small struct like TerminalGuard with Drop that calls
disable_raw_mode() and executes LeaveAlternateScreen) and instantiate it
immediately after the first successful mutation (after enable_raw_mode() and
EnterAlternateScreen execute!); keep a method on the guard to disarm or mark
successful teardown and call that when you run the normal cleanup (before
Terminal::draw/Terminal::new teardown completes); leave panic_hook/set_hook
as-is but rely on the guard to ensure cleanup on any early returns or normal
exit.
```

</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>🤖 Prompt for all review comments with AI agents</summary>

Verify each finding against the current code and only fix it if needed.

Inline comments:
In @tui/src/app.rs:

  • Around line 603-607: The quit-on-q behavior in handle_chat_key currently
    triggers whenever KeyCode::Char('q') and !self.chat_streaming, preventing typing
    'q' into the chat; update the match arm so quitting only occurs when the input
    buffer is empty (match the same empty-input gating used by other chat shortcuts)
    — i.e., require both !self.chat_streaming and
    self.<input_buffer_field>.is_empty() before setting self.should_quit = true;
    modify the KeyCode::Char('q') branch accordingly so normal typing of 'q' in the
    chat box is allowed.
  • Line 553: The unknown-command fallback is leaking typed secrets because the
    "api-key " action isn't handled and falls through to the match arm that calls
    set_status(...) which appends the full message into status_message and
    log_entries; fix this by adding an explicit handler for the "api-key " command
    (or by detecting the "api-key " prefix before the match) that accepts the
    entered key but does not echo it back verbatim — instead call set_status with a
    masked message like "API key updated" (or call a new method that updates state
    without logging the secret), and ensure set_status is not given the raw secret;
    reference the command parsing match in app.rs and the set_status, status_message
    and log_entries symbols when implementing this change.
  • Around line 506-523: The "toggle-health" branch always sets
    disable_health_check: Some(true) so it never re-enables checks; change it to
    compute the next boolean from the selected worker's current state (e.g., via
    self.selected_worker_id() -> lookup worker record/state and read
    worker.health.disable_health_check) and pass the inverted value into
    openai_protocol::worker::HealthCheckUpdate.disable_health_check before calling
    self.client.update_worker(&id, &update). Update the same logic in the other
    occurrence (lines ~876-900) or, if you intend a one-way action, rename the
    action/label to "disable-health" to reflect that it only disables.
  • Around line 765-779: The selected worker resolution must use the same filtered
    view as the UI; change the logic so all actions (start_delete,
    priority/cost/flush/toggle handlers) map selected_index into the filtered worker
    list rather than the unfiltered wl.workers. Implement a single helper (e.g.,
    get_filtered_workers or refactor the filtering block) that returns the Vec of
    filtered worker refs using the active_filter logic currently in start_delete,
    and have selected_worker_id(), selected_worker_url(), and the action methods
    call that helper and index into its result; ensure you handle out-of-range
    selected_index safely (None) and replace any direct indexing into wl.workers
    with lookups into the filtered list.
  • Around line 727-745: The current loop in state.workers processing (iterating
    workers.workers and checking w.runtime_type and w.models) wrongly filters
    external worker models to those starting with "gpt-5.4", dropping valid external
    provider model IDs; remove the is_external && !m.id.starts_with("gpt-5.4") check
    so you always collect each reported m.id into models (checking duplicates as
    already done). Keep the wildcard handling for when w.models.is_empty(), but stop
    hard-coding "gpt-5.4-nano" as the placeholder — instead use a provider-specific
    default if the worker exposes one (e.g., a w.default_model or similar) or fall
    back to a generic "external-model" placeholder so external providers
    (Anthropic/xAI/Gemini/OpenAI variants) are selectable.
  • Around line 1217-1251: On registration failure in the add_worker() match arm,
    undo the prior spawn/claim: remove the corresponding entry from
    self.worker_children (the tuple containing desc and child), terminate or kill
    the spawned child process (the child from the tuple) and await/clean up its
    handle, and remove the claimed GPU entry from self.claimed_gpus keyed by url
    (which was set from selected_gpus); keep the current error log and status
    updates but ensure the worker process is stopped and the GPU claim released to
    avoid phantom busy GPUs.
  • Around line 444-453: The :delete branch currently calls
    self.client.delete_worker(...) directly, which only removes the worker from the
    gateway and leaves local resources (spawned backend and claimed_gpus) orphaned;
    change this branch to reuse the same cleanup/confirmation flow used by the
    interactive delete dialog (the block later in the file that kills the spawned
    backend and releases claimed_gpus) instead of calling self.client.delete_worker
    directly — either call that existing confirmation/cleanup function or extract
    the cleanup logic into a shared method (e.g., delete_worker_with_cleanup or
    confirm_and_delete_worker) and invoke it from the Some("delete") arm, keeping
    the same status updates via self.set_status.

In @tui/src/event.rs:

  • Around line 31-32: Replace the comment prefix "Safety:" with "INVARIANT:" in
    the inline doc above the fire-and-forget event reader loop comment (the comment
    directly above the #[expect(clippy::disallowed_methods)] attribute in event.rs)
    so it follows the repo convention of using INVARIANT: for assumptions in safe
    code; keep the rest of the text unchanged and only rename the marker.

In @tui/src/main.rs:

  • Around line 197-199: The current extract_port function misparses URLs with
    paths because it splits on ':' and grabs the trailing segment; replace this
    brittle string logic with proper URL parsing: use
    url::Url::parse(url).ok().and_then(|u| u.port()) (i.e., call Url::parse in
    extract_port and return u.port()), so inputs like
    "http://localhost:31000/health" return 31000; add the url crate if it's not
    already a dependency and update extract_port to use Url parsing instead of
    rsplit.

In @tui/src/ui/chat.rs:

  • Around line 154-164: The current wrapped-line calculation uses byte counts
    (span.content.len()) which miscounts Unicode/emoji/CJK; replace that with
    display width using unicode-width's UnicodeWidthStr::width (add the
    unicode-width crate and import UnicodeWidthStr), i.e. compute each span's
    display width via span.content.width(), sum those to get line_width, then
    perform the same ceil division by width to produce the u16 contribution to
    total_lines (refer to variables/expressions: total_lines, lines, line.spans,
    span.content, and width).

In @tui/src/ui/detail.rs:

  • Around line 32-34: Change the comment marker from "Safety:" to "INVARIANT:"
    for the non-unsafe RwLock read assumption: update the comment above the call to
    app.state.read().unwrap() to begin with "INVARIANT:" and retain the explanation
    that the RwLock is not poisoned (and keep the #[expect(clippy::unwrap_used)]
    attribute) so the repository convention of reserving SAFETY: for unsafe blocks
    is followed.

In @tui/src/ui/pulse.rs:

  • Around line 371-389: In render_throughput_compact the UI reads latest from
    throughput_history but labels it as req/s and also uses throughput_history
    emptiness to decide "No data"; change the logic to use requests_per_sec_history
    instead: check requests_per_sec_history.is_empty() for the "No data" early
    return and compute latest from
    requests_per_sec_history.back().copied().unwrap_or(0.0), keeping the displayed
    label "Latest: {latest:.1} req/s"; leave throughput_history usage elsewhere
    unchanged.

In @tui/src/ui/sparkline.rs:

  • Around line 46-50: Clamp the input ratio in gauge_bar to the [0.0, 1.0] range
    before computing pct and filled so the displayed percentage (pct) and bar fill
    stay consistent; inside pub fn gauge_bar(ratio: f64, width: usize) create a
    local let r = ratio.clamp(0.0, 1.0) (or equivalent), then compute pct from r and
    compute filled using r and width, leaving empty and return values unchanged.

In @tui/src/ui/workers.rs:

  • Around line 299-304: The truncate() function currently slices by byte index
    (&s[..max - 1]) which can panic on multi-byte UTF-8 characters (like emoji) —
    replace byte-slicing with character-based truncation: when s.len() > max,
    collect the first (max - 1) Unicode scalar values (e.g., s.chars().take(max -
    1).collect::()) and append the ellipsis; keep the original behavior for
    short strings. Update the truncate function (used for worker.id) to avoid any
    direct byte-index slicing and operate on chars to ensure UTF-8 safety.

Duplicate comments:
In @tui/README.md:

  • Around line 66-70: The three unlabeled fenced code blocks in tui/README.md
    (the ASCII status table starting with "WORKERS CIRCUIT BREAKERS", the
    key legend line starting with "a:TUI b:SMG c:Llama-37595", and the command
    list starting with ":add ") are triggering MD040; add the language
    identifier text to each opening triple-backtick fence (i.e., change ``` to
properly labeled.
- Around line 148-149: Choose a single canonical chat endpoint and make all
README occurrences consistent: replace `/v1/chat/completions` with `/v1/chat`
(or vice versa) throughout the file, and update any example request/response
blocks, headings, and references that mention `/v1/chat` or
`/v1/chat/completions` so they match; also ensure the section that contrasts
chat with the Responses API still references `previous_response_id` and
`/v1/responses` correctly.

In `@tui/src/chat.rs`:
- Around line 163-182: The completed/done branch duplicates assistant output
when prior "response.output_text.delta" chunks were already sent; introduce a
boolean flag (e.g., has_sent_deltas or saw_delta) in the surrounding scope, set
it to true inside the "response.output_text.delta" arm where
tx.send(delta.to_string()) is called, and in the "response.completed" |
"response.done" arm skip sending the full aggregated text (and only send the
final "[DONE]" marker) if that flag is true; ensure the flag is referenced where
tx.send is used so only one representation of the assistant response is emitted.

In `@tui/src/client.rs`:
- Around line 264-266: The streaming code builds a new reqwest::Client inside
stream_request which prevents connection pooling and TLS session reuse; instead
add a dedicated streaming client field (e.g., stream_client: reqwest::Client) to
the client struct initialized in the constructor using
Client::builder().timeout(Duration::from_secs(120)). Replace the local builder
call in stream_request with a reuse of self.stream_client so all streaming
requests reuse the same client and timeout configuration.

In `@tui/src/event.rs`:
- Around line 46-53: The Event::Key branch currently forwards all key events;
change the match arm handling Some(Ok(Event::Key(key))) so it only sends
AppEvent::Key when key.kind == KeyEventKind::Press (ignore KeyEventKind::Repeat
and KeyEventKind::Release) — update the pattern/if guard around the Event::Key
arm and ensure KeyEventKind is referenced/imported so
tx.send(AppEvent::Key(key)) is only called for KeyEventKind::Press.

In `@tui/src/main.rs`:
- Around line 158-170: The terminal setup currently relies only on a panic hook
and can leak terminal state if an early `?` returns; create a RAII guard (e.g.,
a small struct like TerminalGuard with Drop that calls disable_raw_mode() and
executes LeaveAlternateScreen) and instantiate it immediately after the first
successful mutation (after enable_raw_mode() and EnterAlternateScreen execute!);
keep a method on the guard to disarm or mark successful teardown and call that
when you run the normal cleanup (before Terminal::draw/Terminal::new teardown
completes); leave panic_hook/set_hook as-is but rely on the guard to ensure
cleanup on any early returns or normal exit.

In `@tui/src/state.rs`:
- Around line 395-398: throughput_history is being appended twice per poll cycle
because total_throughput is pushed unconditionally and later rps (from
Prometheus) can also push; update the logic so the sparkline gets a single
source per cycle: prefer rps when available by making the first push conditional
(e.g., only push total_throughput into s.throughput_history when rps is not
available) or convert the two pushes into mutually exclusive branches; touch the
block that uses SPARKLINE_CAP and total_throughput as well as the later branch
that pushes rps to ensure only one push to s.throughput_history happens per
cycle.
- Around line 95-105: spawn_poller currently drops the tokio::task::JoinHandle
which prevents graceful shutdown; change spawn_poller to return the JoinHandle
(tokio::task::JoinHandle<()>) instead of () by capturing the result of
tokio::spawn and returning it, update callers to hold and abort/await the handle
during shutdown, and ensure the signature in state.rs (spawn_poller) and any
call sites are updated to propagate or store the returned JoinHandle so the
poller can be cancelled or awaited cleanly.
- Around line 451-466: The subtraction cur_sum - prev_sum in the
parse_duration_stats handling can produce negative delta_sum when Prometheus
resets counters; update the logic in the block around parse_duration_stats /
s.prev_duration_sum / s.prev_duration_count so you compute delta_sum = (cur_sum
as f64 - prev_sum as f64) and then guard: if delta_sum > 0.0 && delta_count > 0
{ avg_latency = delta_sum / delta_count as f64; push to s.avg_latency_history
(evict oldest when >= SPARKLINE_CAP) } else skip pushing (or treat avg_latency
as 0.0 but do not push negative values). Ensure prev_duration_sum and
prev_duration_count are still updated after the check.

In `@tui/src/ui/action_menu.rs`:
- Around line 145-167: The SelectModel branch is leaking numeric labels via
Box::leak each frame; stop allocating leaked &'static str by switching to owned
strings and updating the menu renderer. Build items as Vec<(String, String,
String)> (create the numeric label with format! without Box::leak) in the
AddMenuState::SelectModel arm, then change render_menu to accept owned String
tuples (or a slice of (String,String,String)) instead of &str references and
update any call sites accordingly so you can pass the owned items directly
without leaking memory.

In `@tui/src/ui/detail.rs`:
- Around line 225-231: The truncate_str function slices by byte index
(&s[..max.saturating_sub(1)]) which can panic on multi-byte UTF-8 characters;
change truncate_str to perform a character-aware truncate (e.g., iterate
s.chars().take(...) or use char_indices to find a valid byte boundary) so you
build a new String of at most max-1 characters and then append the ellipsis,
ensuring you handle the case s.len() <= max by returning s.to_string(); update
the function truncate_str accordingly to avoid direct byte-slicing.
- Around line 130-143: The circuit display currently derives cb_label and
cb_color from worker.is_healthy which is incorrect; instead query the shared
breaker state/store for this worker (e.g., call the circuit state accessor for
the worker id or use worker.circuit_state / worker.metrics.circuit_open if
available) and base cb_label ("open"/"closed") and cb_color
(theme::RED/theme::GREEN) on that boolean. Update the code that builds the
"Circuit:" Line (the Span/Style creation around cb_label/cb_color and the
variables cb_label/cb_color) to use the true circuit boolean from the shared
metrics/state rather than worker.is_healthy. Ensure the accessor you use is
thread-safe and present in scope before replacing references.

In `@tui/src/ui/dialog.rs`:
- Around line 40-43: Destructure the tuple `info` into named locals (e.g., `let
(id, url) = info;`) before building the dialog text and anywhere else `info.0` /
`info.1` are used (the `format!` call that produces the "Delete worker?" text
and the other dialog usage later), then replace `info.0` and `info.1` with the
new `id` and `url` identifiers to improve readability in functions handling the
dialog text (where `info` is referenced).
- Around line 19-30: Extract the duplicated centering/layout code into a small
helper (e.g., fn centered_popup(area: Rect, width: u16, height: u16) -> Rect)
that performs the Layout::vertical/Horizontal sequence using Constraint::Length
and Flex::Center and returns the resulting popup rect; replace both occurrences
that currently bind [_, vert, _] and [popup] with a call to centered_popup(area,
50, 8) (or the appropriate width/height) so the dialog drawing code uses the
single helper instead of duplicating the Layout logic. Ensure the helper
references Layout, Constraint, and Flex and accepts the input `area` so both
existing call sites (the blocks creating `vert`/`popup`) can be swapped to the
new function.

In `@tui/src/ui/footer.rs`:
- Around line 25-37: The footer currently advertises only "1-5" views in the
hint(...) calls; update both occurrences of hint("1-5", "view") in the footer
generation (the branch bodies shown around the vec![...] blocks in
tui::ui::footer.rs) to advertise "1-7" so the footer reflects the seven tabs
(Pulse, Workers, Chat, Logs, Benchmark, Traffic, Mesh) added by this PR.

In `@tui/src/ui/help.rs`:
- Around line 40-48: Update the duplicate section header inside the string
passed to text.push_str so the second "Navigation" block is renamed to
"Selection" (or similar) to clarify it refers to list/item movement rather than
view switching; locate the string literal in tui/src/ui/help.rs where
text.push_str(...) is called and change the header line "Navigation" to
"Selection" while leaving the rest of the key descriptions unchanged.

In `@tui/src/ui/logs.rs`:
- Around line 163-207: render_file_log currently does blocking disk I/O and
recomputes the file tail every frame; move file reading and line processing into
a background poller that maintains a cached tail on the App and make
render_file_log read only that cached data. Implement a background task (spawned
at app init) that reads the log file path, trims to max_lines (500), strips ANSI
and maps to styled Line objects once, and stores the result in a thread-safe
field on App (e.g., Arc<RwLock<Vec<Line>>> or similar); update the poller at an
interval (e.g., 250–500ms) and handle file-not-found there. Change
render_file_log to fetch the precomputed Vec<Line> from App and render it
without doing any file I/O or heavy processing (leave strip_ansi only in the
poller), and keep the existing formatting logic but applied only in the
background updater so rendering is non-blocking.

In `@tui/src/ui/models.rs`:
- Line 18: The header Row::new currently labels the column "OWNER" but the
rendered cell uses m.display_name, causing a mismatch; update the header text in
the Row::new call (the header variable) to match the rendered field (e.g.,
"DISPLAY NAME" or "NAME"), or alternatively change the rendering where
m.display_name is used to render an actual owner field; locate the Row::new(...)
that defines header and the rendering loop that uses m.display_name and make the
header and rendered field names consistent.

In `@tui/src/ui/pulse.rs`:
- Around line 21-38: The narrow-layout branch returns too early and omits
request stats; add another row to the constraints Vec (another
Constraint::Fill(1)) when building the single-column layout and call
render_request_stats(f, &state, rows[i]) in the sequence before the final
render_throughput_compact (adjusting the index increments to account for the
extra row and respecting has_node_panel), so Layout::vertical(...).split(area)
produces an extra rows entry and render_request_stats is invoked for small
terminals.

In `@tui/src/ui/stats_bar.rs`:
- Around line 78-85: The health-text logic incorrectly shows "all healthy" when
total == 0; update the health_text assignment in stats_bar.rs to explicitly
handle the zero-worker case before checking unhealthy: when state.connected is
true and total == 0 return a clear indicator (e.g., "--" or "no workers", with
theme::TEXT_MUTED) instead of "all healthy"; keep the existing branches for
state.connected == false, unhealthy == 0, and unhealthy > 0, referencing the
variables unhealthy, total, healthy and state.connected so the check order is
total == 0 first.

In `@tui/src/ui/tabs.rs`:
- Around line 14-32: Replace the manual index calculation i + 1 with the
view-provided index to keep numbering consistent: when building the tab Spans in
the iterator over View::all(), call view.index() (instead of using the
enumerated i) to produce the tab number string used in format!("{num}:{label}")
— keep using view.label() for the label and the existing style/active comparison
with active to preserve behavior.

In `@tui/src/ui/workers.rs`:
- Around line 269-283: Compute a single clamped selection index and use it for
both the table selection and detail lookup instead of using app.selected_index
twice; e.g., create a local let selected = if row_count > 0 {
app.selected_index.min(row_count.saturating_sub(1)) } else { 0 }; call
table_state.select(Some(selected)) and then use filtered.get(selected) when
calling detail::render_detail so the highlighted row and detail pane always
refer to the same clamped index.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0fdd8da1-311b-4f9f-9662-e2c4b7ae6f72

📥 Commits

Reviewing files that changed from the base of the PR and between 263b44d and 50b7078.

⛔ Files ignored due to path filters (2)
  • tui/assets/add-worker.gif is excluded by !**/*.gif
  • tui/assets/tui-demo.gif is excluded by !**/*.gif
📒 Files selected for processing (28)
  • .pre-commit-config.yaml
  • Cargo.toml
  • tui/Cargo.toml
  • tui/README.md
  • tui/src/app.rs
  • tui/src/chat.rs
  • tui/src/client.rs
  • tui/src/event.rs
  • tui/src/lib.rs
  • tui/src/main.rs
  • tui/src/state.rs
  • tui/src/types.rs
  • tui/src/ui/action_menu.rs
  • tui/src/ui/chat.rs
  • tui/src/ui/detail.rs
  • tui/src/ui/dialog.rs
  • tui/src/ui/filter.rs
  • tui/src/ui/footer.rs
  • tui/src/ui/help.rs
  • tui/src/ui/logs.rs
  • tui/src/ui/mod.rs
  • tui/src/ui/models.rs
  • tui/src/ui/pulse.rs
  • tui/src/ui/sparkline.rs
  • tui/src/ui/stats_bar.rs
  • tui/src/ui/tabs.rs
  • tui/src/ui/theme.rs
  • tui/src/ui/workers.rs

Comment thread tui/src/app.rs
Comment thread tui/src/app.rs
Comment thread tui/src/app.rs
Comment thread tui/src/app.rs
Comment thread tui/src/app.rs Outdated
Comment thread tui/src/ui/chat.rs
Comment thread tui/src/ui/detail.rs
Comment thread tui/src/ui/pulse.rs
Comment thread tui/src/ui/sparkline.rs
Comment thread tui/src/ui/workers.rs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 95a1ee0fe9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tui/src/app.rs Outdated
Comment thread tui/src/app.rs Outdated
Comment thread tui/src/app.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (27)
tui/src/event.rs (2)

31-32: 🧹 Nitpick | 🔵 Trivial

Use INVARIANT: instead of Safety: here.

This is documenting a safe-code assumption, not an unsafe soundness requirement.

Based on learnings: "In Rust code across the repository, use the marker INVARIANT: to document assumptions in safe code. Reserve SAFETY: for explaining why unsafe blocks are sound."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/event.rs` around lines 31 - 32, Change the comment prefix from
"Safety:" to "INVARIANT:" for the safe-code assumption in the event reader
description so it follows repository convention; update the comment that
currently reads "// Safety: fire-and-forget event reader loop that runs for the
app's lifetime" to use "INVARIANT:" instead (this is right above the
#[expect(clippy::disallowed_methods)] attribute in event.rs and refers to the
event reader loop assumption).

1-1: ⚠️ Potential issue | 🟠 Major

Only enqueue KeyEventKind::Press events.

Forwarding every Event::Key will double-fire actions on terminals that emit Repeat/Release. Filter here so the rest of the app only sees key presses.

🩹 Proposed fix
-use crossterm::event::{Event, EventStream, KeyEvent};
+use crossterm::event::{Event, EventStream, KeyEvent, KeyEventKind};
...
-                            Some(Ok(Event::Key(key)))
-                                if tx.send(AppEvent::Key(key)).is_err() => {
-                                    break;
-                                }
+                            Some(Ok(Event::Key(key))) if key.kind == KeyEventKind::Press => {
+                                if tx.send(AppEvent::Key(key)).is_err() {
+                                    break;
+                                }
+                            }
+                            Some(Ok(Event::Key(_))) => {}
In crossterm 0.28.x, can Event::Key emit KeyEventKind::Repeat/Release, and do Ratatui examples recommend handling only KeyEventKind::Press to avoid duplicate key processing?

Also applies to: 44-55

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/event.rs` at line 1, The event forwarder is emitting all crossterm
Event::Key variants causing duplicate actions on Repeat/Release; update the code
that matches Event::Key (in tui/src/event.rs) to only enqueue/forward when the
contained KeyEvent has kind == KeyEventKind::Press (i.e., filter Event::Key(k)
by k.kind == KeyEventKind::Press) so the rest of the app receives only key
presses.
tui/src/ui/workers.rs (3)

299-304: ⚠️ Potential issue | 🔴 Critical

Make truncate() UTF-8 safe.

This currently treats max as a byte limit, not a character limit, so non-ASCII IDs/URLs/model lists can truncate too early or panic when &s[..max - 1] lands inside a codepoint.

🩹 Proposed fix
 fn truncate(s: &str, max: usize) -> String {
-    if s.len() <= max {
+    if s.chars().count() <= max {
         s.to_string()
     } else {
-        format!("{}…", &s[..max - 1])
+        let head: String = s.chars().take(max.saturating_sub(1)).collect();
+        format!("{head}…")
     }
 }
In Rust, can slicing a &str with byte indices like &s[..n] panic when n is not on a UTF-8 character boundary?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/workers.rs` around lines 299 - 304, The truncate(s: &str, max:
usize) function currently slices by bytes which can panic on UTF-8 boundaries;
change it to treat max as a character limit by operating on chars rather than
byte indices (e.g., iterate s.chars() and collect up to max characters,
appending the ellipsis when the original string has more than max chars). Update
truncate to check character count, return s.to_string() when length <= max,
otherwise build a new String from the first max-1 characters (or first max and
then replace last with ellipsis per existing behavior) using char iteration so
all slicing is UTF-8 safe; reference function name truncate in this file.

269-283: ⚠️ Potential issue | 🟠 Major

Reuse the clamped selection for the detail pane.

The table highlight clamps selected_index, but Lines 280-282 still use the raw index. After filtering or worker deletion, the highlighted row can differ from the detail pane or leave it blank.

🩹 Proposed fix
-    let mut table_state = TableState::default();
-    if row_count > 0 {
-        table_state.select(Some(app.selected_index.min(row_count.saturating_sub(1))));
-    }
+    let selected = if row_count > 0 {
+        Some(app.selected_index.min(row_count.saturating_sub(1)))
+    } else {
+        None
+    };
+
+    let mut table_state = TableState::default();
+    table_state.select(selected);
@@
-    if let Some(detail_area) = detail_area {
-        if let Some(worker) = filtered.get(app.selected_index) {
+    if let Some(detail_area) = detail_area {
+        if let Some(worker) = selected.and_then(|idx| filtered.get(idx)) {
             detail::render_detail(f, app, worker, detail_area);
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/workers.rs` around lines 269 - 283, The detail pane currently uses
the raw app.selected_index which can be out-of-sync with the clamped selection
used for the table; after you call table_state.select(...) read the clamped
index from table_state.selected() and use that to look up the worker for
detail::render_detail instead of app.selected_index. Concretely, after building
table_state use something like if let Some(clamped) = table_state.selected() {
if let Some(worker) = filtered.get(clamped) { detail::render_detail(f, app,
worker, detail_area); } } so the detail pane always follows the table's actual
highlighted row.

326-346: ⚠️ Potential issue | 🟠 Major

Don't drop extra load entries or plain-HTTP fallbacks.

details.loads.first() under-reports TP>1 workers, and the fallback only covers grpc:///https://. Plain http:// workers fall through to "0" whenever /get_loads is unavailable even though worker_rps is already available.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/workers.rs` around lines 326 - 346, The current code drops extra
load entries by using details.loads.first() and also fails to use worker_rps for
plain "http://" fallbacks; change the logic in the worker-load formatting block
to (1) aggregate across all entries in details.loads (e.g., total_running =
sum(load.num_running_reqs) and token_usage = max(load.token_usage) or a sensible
aggregate) instead of details.loads.first(), and (2) move/extend the
rps-fallback to cover plain "http://" (or, more generally, if worker_rps
contains an entry use that) so that when /get_loads is missing you return
(format!("{rps:.1} r/s"), "N/A".to_string()) rather than ("0", "0.0%").
Reference symbols: loads.get(worker_url), wl.details, details.loads,
worker_url.starts_with(...), and worker_rps.get(worker_url).
tui/src/ui/tabs.rs (1)

14-18: 🧹 Nitpick | 🔵 Trivial

Use View::index() as the tab number source.

enumerate() + i + 1 duplicates numbering logic that View already owns, so the rendered shortcut can drift if the enum numbering changes. view.index() keeps the tab label aligned with the rest of the navigation code.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/tabs.rs` around lines 14 - 18, The tab numbering currently uses
enumerate() and i + 1 when building tabs from View::all(), which duplicates
View's own numbering logic; update the closure that constructs the tab label
(the block producing num = format!("{}", i + 1)) to use view.index() as the
source of the tab number instead (call view.index() and format that value),
removing reliance on enumerate() for numbering so the rendered shortcut stays
consistent with View::index() across the codebase.
tui/src/ui/footer.rs (1)

25-25: ⚠️ Potential issue | 🟡 Minor

Advertise all seven views in the footer.

Both normal-mode hint sets still say 1-5, but the tab bar exposes seven views. That makes Traffic and Mesh look unavailable from the keyboard.

🩹 Proposed fix
-                    hint("1-5", "view"),
+                    hint("1-7", "view"),
...
-                    hint("1-5", "view"),
+                    hint("1-7", "view"),

Also applies to: 37-37

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/footer.rs` at line 25, The footer currently advertises only "1-5"
in the normal-mode hint sets, but the UI exposes seven views; update the two
hint label calls so the footer advertises all seven views. Locate the
hint("1-5", "view") invocations in tui/src/ui/footer.rs (the two occurrences
around the normal-mode hint blocks) and change the displayed range to "1-7"
(e.g., hint("1-7", "view")) so both normal-mode hint sets advertise all seven
views including Traffic and Mesh.
tui/src/ui/models.rs (1)

18-18: ⚠️ Potential issue | 🟡 Minor

Fix the OWNER column label or the rendered value.

Line 18 says OWNER, but Line 51 renders m.display_name. That makes the table misleading as soon as display name differs from the owner. Rename the header or render the actual owner field.

Also applies to: 49-51

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/models.rs` at line 18, Header text "OWNER" in the table (created
via Row::new in the header variable) does not match the rendered cell value
which uses m.display_name; change one to match the other by either renaming the
header to "NAME" (or "OWNER / NAME") where header is built in Row::new, or
update the cell renderer to display the actual owner field (e.g., replace
m.display_name with m.owner or m.owner_id) in the code that builds the row cells
around where m is used (lines rendering the row, e.g., the code around
m.display_name). Ensure the header label and the value source are consistent
across the table rendering.
tui/src/ui/logs.rs (1)

163-190: ⚠️ Potential issue | 🟠 Major

Move log-file reads and tailing out of render_file_log().

read_to_string() plus content.lines().collect() runs on every frame, so a large or fast-growing log will stall repaint/input exactly when the Logs view is open. Populate a cached tail in the background poller and render only that cached slice here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/logs.rs` around lines 163 - 190, render_file_log currently reads
and parses the entire file on every frame (via std::fs::read_to_string and
content.lines().collect), which blocks the UI; move all file I/O and tailing
into the background poller and have render_file_log only render a cached tail.
Specifically: add a cached tail field (e.g., App.cached_log_tail: Vec<Line> or
String) updated by the poller, change the poller to read the file, compute the
last N lines (max_lines=500), and store the result into that cache using a
thread-safe mechanism (Arc<Mutex<...>> or a channel update on App), and modify
render_file_log to skip read_to_string and instead render from
App.cached_log_tail (still using the same Block/Paragraph rendering and the
existing title/label logic).
tui/src/ui/chat.rs (2)

22-37: ⚠️ Potential issue | 🟡 Minor

Show the real chat-completions path in the title.

For ChatEndpoint::Chat, this renders /v1/chat, but tui/src/chat.rs actually posts to /v1/chat/completions. The title becomes misleading when users compare the TUI against logs/docs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/chat.rs` around lines 22 - 37, The title shows an incorrect path
for ChatEndpoint::Chat; update the title formatting in chat UI to display the
actual POST path used by the client ("/v1/chat/completions") instead of
"/v1/chat" when app.chat_endpoint is Chat; locate the code that builds the title
(uses app.chat_endpoint, ChatEndpoint::Chat and app.chat_endpoint.label()) and
either change the label() call for Chat to return "chat/completions" or
special-case Chat in this formatting to insert "/v1/chat/completions" so the
displayed path matches the request path used by tui/src/chat.rs.

152-164: ⚠️ Potential issue | 🟡 Minor

Use display width, not byte length, for scroll math.

span.content.len() counts bytes, so emoji/CJK text skews total_lines and makes the wrapped scroll range drift from what Ratatui actually renders.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/chat.rs` around lines 152 - 164, The scroll math is using byte
length via span.content.len(), which miscalculates display width for emoji/CJK;
change the width calculation in the total_lines mapping to use a display-width
function (e.g. UnicodeWidthStr::width) on each span's content instead of len(),
keep the max(1) and ceil division logic, and add the necessary import for the
unicode-width helper so total_lines reflects visual columns rather than byte
count.
tui/src/ui/stats_bar.rs (1)

79-85: ⚠️ Potential issue | 🟡 Minor

Handle the zero-worker case explicitly.

When the gateway is connected but total == 0, this still renders green all healthy. That should be a neutral no workers/-- state instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/stats_bar.rs` around lines 79 - 85, The health_text branch logic
treats a connected gateway with total == 0 as "all healthy"; change health_text
(used where state.connected, unhealthy, total are referenced) so that when
state.connected is true and total == 0 it returns the neutral/no-workers variant
(e.g., "--" or "no workers" with theme::TEXT_MUTED) before checking unhealthy ==
0; reorder the conditions accordingly so the total==0 case is handled first
while preserving the existing GREEN for actual healthy workers and RED for
unhealthy cases.
tui/src/main.rs (2)

158-171: ⚠️ Potential issue | 🟠 Major

Guard terminal setup with RAII, not just the panic hook.

Any ? between enable_raw_mode() and the explicit cleanup path can return before the hook helps, leaving the shell in raw mode / alternate screen.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/main.rs` around lines 158 - 171, The terminal setup must be protected
by an RAII guard instead of relying only on the panic hook: create a small guard
type (e.g. TerminalGuard) whose constructor calls enable_raw_mode() and
EnterAlternateScreen and whose Drop impl calls LeaveAlternateScreen and
disable_raw_mode(), then instantiate this guard before creating the
CrosstermBackend/Terminal (symbols: enable_raw_mode, disable_raw_mode,
EnterAlternateScreen, LeaveAlternateScreen, CrosstermBackend::new,
Terminal::new) so any early-return or error will run Drop and restore the
terminal; keep or call std::panic::set_hook as before, but remove cleanup logic
from the hook since the guard handles restoration.

197-199: ⚠️ Potential issue | 🟠 Major

Parse the port from a URL, not by splitting on :.

rsplit(':') misparses values like http://localhost:31000/health as 31000/health, so auto-start falls back to the default port and may launch SMG on the wrong socket.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/main.rs` around lines 197 - 199, The current extract_port function
incorrectly slices on ':' and misparses paths like
"http://localhost:31000/health"; change extract_port to actually parse the URL
(use the url::Url parser) and return the port via Url::port() or
Url::port_or_known_default() to get the numeric port correctly; update
Cargo.toml to include the url crate if missing and replace the rsplit-based
logic in extract_port with a proper Url::parse(url) flow that returns the parsed
port as Option<u16>.
tui/src/ui/pulse.rs (2)

376-389: ⚠️ Potential issue | 🟠 Major

Use request-rate history in the compact throughput panel.

This panel reads throughput_history but labels it req/s. It also shows No data whenever tok/s history is empty, even if request-rate history exists.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/pulse.rs` around lines 376 - 389, The compact throughput panel is
wrongly using state.throughput_history (and showing "No data" when tok/s history
is empty) but should use the request-rate history; update the panel logic in
pulse.rs to check state.request_rate_history for emptiness, use
state.request_rate_history.back().copied().unwrap_or(0.0) for latest, and
replace any uses of state.throughput_history in this panel (rendering the
Paragraph and any sparkline) with state.request_rate_history so the label
"req/s" matches the actual data shown.

21-38: ⚠️ Potential issue | 🟠 Major

Keep Request Stats in the narrow layout.

The <80 branch returns after rendering worker health / node status / throughput, so latency, connections, and in-flight metrics disappear entirely on small terminals.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/pulse.rs` around lines 21 - 38, The narrow-layout branch for width
< 80 omits the request-stats panel (latency/connections/in-flight); update the
branch that builds constraints and renders rows so it includes a Constraint and
a call to the request-stats renderer (e.g., add Constraint::Fill(1) to the
constraints Vec and call render_request_stats(f, &state, rows[i]) in sequence
before returning), making sure to account for has_node_panel when computing the
row index similarly to render_worker_health, render_node_status, and
render_throughput_compact.
tui/src/ui/detail.rs (3)

32-34: 🧹 Nitpick | 🔵 Trivial

Use INVARIANT: for this safe-code assumption.

This comment documents a lock-poisoning assumption, not an unsafe block.

Based on learnings: "In Rust code across the repository, use the marker INVARIANT: to document assumptions in safe code. Reserve SAFETY: for explaining why unsafe blocks are sound."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/detail.rs` around lines 32 - 34, Replace the "Safety:" note above
the unwrap with the repository's invariant marker: change the comment before the
call to app.state.read().unwrap() to start with "INVARIANT:" (e.g., "INVARIANT:
RwLock is not poisoned — no panics while holding the lock") so the assumption is
documented as a safe-code invariant rather than a SAFETY explanation; leave the
#[expect(clippy::unwrap_used)] and the unwrap call unchanged.

225-230: ⚠️ Potential issue | 🔴 Critical

Make truncate_str UTF-8 safe.

&s[..max.saturating_sub(1)] panics when max lands inside a multibyte character, which can crash the detail panel on non-ASCII worker IDs.

Proposed fix
 fn truncate_str(s: &str, max: usize) -> String {
-    if s.len() <= max {
+    if max == 0 {
+        String::new()
+    } else if s.chars().count() <= max {
         s.to_string()
     } else {
-        format!("{}…", &s[..max.saturating_sub(1)])
+        let prefix: String = s.chars().take(max.saturating_sub(1)).collect();
+        format!("{prefix}…")
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/detail.rs` around lines 225 - 230, truncate_str currently slices
bytes which can split UTF-8 multibyte characters; update truncate_str to operate
on char boundaries: check if s.chars().count() <= max then return s.to_string(),
otherwise build the prefix with
s.chars().take(max.saturating_sub(1)).collect::<String>() and append the
ellipsis (e.g., prefix + "…"); handle the max == 0 case by returning "…" (or an
empty string plus ellipsis) so no byte-slice is ever used and multibyte
characters are preserved.

130-143: ⚠️ Potential issue | 🟠 Major

Don't proxy circuit-breaker state through worker.is_healthy.

This renders renamed health status, not breaker status. A healthy worker with an open breaker will show as closed, and an unhealthy worker will show as open. Use the real breaker signal here, or render unknown until that data is available.

Based on learnings: "healthy_only = true intentionally separates circuit-breaker-open workers from genuinely unhealthy workers; the 503 path is specifically for healthy workers whose circuit breaker is open."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/detail.rs` around lines 130 - 143, The UI is incorrectly using
worker.is_healthy to represent circuit-breaker state (cb_label/cb_color and the
"Circuit: " span), causing healthy-but-open breakers to display as "closed";
update this block to read the actual breaker signal (e.g., a field like
worker.circuit_open, worker.breaker_state, or similar) when available and map
that to labels/colors, and if no breaker state is present render "unknown"
instead; also keep the existing health-only semantics (healthy_only) intact so
the 503 path still applies for healthy workers with an open breaker.
tui/src/ui/action_menu.rs (1)

145-167: ⚠️ Potential issue | 🟠 Major

Remove the per-frame Box::leak allocations in the model picker.

This branch runs on every render while SelectModel is open, so the leaked numbering strings grow the heap until the process exits. Keep the indices owned and render from String/Cow instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/action_menu.rs` around lines 145 - 167, The code leaks heap
strings via Box::leak in AddMenuState::SelectModel; replace those leaks by
keeping owned Strings (or Cow) for the index labels so they live for the
duration of the render and can be borrowed when building refs. Concretely,
change items to Vec<(String, String, String)> (build index labels with
format!("{}", i+1) and the custom label with format!("{}", presets.len()+1)),
push those Strings into items, then create refs by borrowing with .as_str()
(e.g. .map(|(n,l,d)| (n.as_str(), l.as_str(), d.as_str())) ) before calling
render_menu; this removes Box::leak while keeping the same render_menu call
pattern.
tui/src/app.rs (5)

633-635: ⚠️ Potential issue | 🟠 Major

Pressing q quits even while typing in the chat input.

In handle_chat_key, pressing q when !self.chat_streaming always sets should_quit = true. Unlike other view-switching keys (lines 647-659) which check self.chat_input.is_empty(), this prevents typing the letter 'q' in chat messages.

Proposed fix
-            KeyCode::Char('q') if !self.chat_streaming => {
+            KeyCode::Char('q') if !self.chat_streaming && self.chat_input.is_empty() => {
                 self.should_quit = true;
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/app.rs` around lines 633 - 635, In handle_chat_key, the
KeyCode::Char('q') branch sets self.should_quit = true unconditionally when
!self.chat_streaming, preventing typing 'q' into the chat; change the condition
to mirror the other view-switching keys by requiring both !self.chat_streaming
and self.chat_input.is_empty() before setting self.should_quit, so that 'q' only
quits when the chat input is empty and not streaming.

1281-1288: ⚠️ Potential issue | 🟠 Major

Roll back spawned worker when gateway registration fails.

When add_worker() fails at line 1273, the child process is already in worker_children (line 1253) and GPUs are claimed (line 1262). The process continues running and claims are never released.

Proposed fix: Clean up on registration failure
                     Err(e) => {
                         self.add_log(
                             LogLevel::Error,
                             &format!("Failed to register worker {url}: {e}"),
                         );
+                        // Roll back: release GPU claim and kill the spawned process
+                        self.claimed_gpus.remove(&url);
+                        if let Some(pos) = self.worker_children.iter().position(|(d, _)| d == &desc) {
+                            let (_, mut child) = self.worker_children.remove(pos);
+                            let _ = child.start_kill();
+                        }
                         self.set_status(format!("Started {desc} but registration failed: {e}"));
                     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/app.rs` around lines 1281 - 1288, On registration failure in the
Err(e) branch after calling add_worker(), ensure you roll back the spawned
worker: remove its entry from worker_children, terminate/kill the child process
(or send shutdown) and release any claimed GPUs (the same logic used when
stopping workers), then log and set status as before; implement this cleanup in
the Err(e) block that currently calls add_log and set_status so the child
process doesn't keep running and GPUs remain freed.

758-774: ⚠️ Potential issue | 🟠 Major

External worker model filtering excludes valid provider models.

The gpt-5.4 prefix check drops models from Anthropic, xAI, Gemini, and most OpenAI models (e.g., gpt-4o, claude-3, gemini-1.5-pro), making them unselectable in the chat tab.

Consider using the models actually reported by each worker, and only apply a provider-specific default for true wildcard workers (empty model list).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/app.rs` around lines 758 - 774, The current loop over workers
(workers.workers) treats any external worker as requiring gpt-5.4-prefixed
models by filtering with m.id.starts_with("gpt-5.4"), which drops valid provider
model ids; change the logic so that if a worker has an empty models list
(w.models.is_empty()) you add the provider-default placeholder "gpt-5.4-nano"
once, but otherwise accept and push every reported model id (m.id) for that
worker without applying the starts_with filter; specifically remove the branch
that does if is_external && !m.id.starts_with("gpt-5.4") { continue } and keep
the existing duplicate-check on models.contains before pushing m.id, and ensure
the placeholder insertion still guards against duplicates.

534-556: ⚠️ Potential issue | 🟠 Major

toggle-health command only disables health checks, never re-enables.

Both the :toggle-health command here and the ToggleHealthCheck menu action at lines 904-930 always send disable_health_check: Some(true). This is a one-way action that cannot re-enable health checks.

Either derive the next state from the worker's current disable_health_check value, or rename to :disable-health / "Disable health check" to clarify the one-way behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/app.rs` around lines 534 - 556, The toggle-health handler (and the
ToggleHealthCheck menu action) always sends disable_health_check: Some(true),
making it one-way; change the logic to read the worker's current
disable_health_check state (e.g., fetch the worker via
self.client.get_worker(&id).await or from the in-memory worker record) and send
disable_health_check: Some(!current_value) when building
openai_protocol::worker::WorkerUpdateRequest so the update toggles between
enabled/disabled; update both the "toggle-health" match arm and the
ToggleHealthCheck action to use the same toggle-by-current-state approach and
preserve other fields as before.

1156-1168: ⚠️ Potential issue | 🟡 Minor

TOCTOU race on ephemeral port allocation.

The TcpListener binds to get a free port, then immediately drops. Between dropping and the worker process binding, another process could claim that port.

While unlikely in practice, consider either:

  1. Passing port 0 directly to the worker runtime if it supports reporting the actual bound port
  2. Implementing retry logic if the worker fails to bind
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/app.rs` around lines 1156 - 1168, The current ephemeral-port
allocation uses std::net::TcpListener to pick a free port then immediately drops
it (the match that assigns port), creating a TOCTOU race; instead either (A)
stop pre-binding and pass port 0 directly to the worker runtime so the worker
itself binds and reports the actual bound port, or (B) add robust retry logic
around worker bind attempts: if you must keep the existing TcpListener pattern
(the code that assigns port and calls self.set_status on error), modify the
worker spawn/bind path to accept port 0 or detect binding failure and retry N
times with a small backoff before reporting failure via self.set_status;
reference the current port variable and the TcpListener bind block when
implementing this change so the worker binding and reporting are atomic and
race-free.
tui/src/state.rs (2)

450-466: ⚠️ Potential issue | 🟡 Minor

Potential negative latency on Prometheus counter reset.

Line 453 uses plain subtraction (cur_sum - prev_sum) which can yield a negative value if the Prometheus server restarts and counters reset to zero. This pushes negative latency values to avg_latency_history.

Proposed fix: Clamp delta_sum to non-negative
         let (cur_sum, cur_count) = parse_duration_stats(&metrics_text);
         if let (Some(prev_sum), Some(prev_count)) = (s.prev_duration_sum, s.prev_duration_count) {
-            let delta_sum = cur_sum - prev_sum;
+            let delta_sum = if cur_sum >= prev_sum { cur_sum - prev_sum } else { 0.0 };
             let delta_count = cur_count.saturating_sub(prev_count);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/state.rs` around lines 450 - 466, The subtraction cur_sum - prev_sum
in the avg latency computation can be negative after a Prometheus counter reset;
in parse_duration_stats usage where you compute delta_sum and delta_count (using
s.prev_duration_sum and s.prev_duration_count) clamp delta_sum to non-negative
before computing avg_latency (e.g., replace raw subtraction with a non-negative
max) so you never push negative values into s.avg_latency_history (respect
existing delta_count handling and SPARKLINE_CAP).

395-427: ⚠️ Potential issue | 🔴 Critical

throughput_history receives duplicate entries when both conditions are met.

When worker metrics are available (has_worker_metrics is true) AND Prometheus metrics fetch succeeds, throughput_history gets pushed twice per poll cycle:

  1. Line 398: pushes total_throughput (from loads API)
  2. Line 427: pushes rps (from Prometheus counter)

This corrupts the sparkline data and causes the rolling history to advance twice as fast as intended.

Proposed fix: Use req/s as sole source when available
     if has_worker_metrics {
         // ... per-worker metrics processing ...

-        if s.throughput_history.len() >= SPARKLINE_CAP {
-            s.throughput_history.pop_front();
-        }
-        s.throughput_history.push_back(total_throughput);
-
         let avg_cache = if worker_count > 0 {

This keeps the Prometheus-based rps as the primary throughput source (lines 424-427), which works for all worker types including external providers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/state.rs` around lines 395 - 427, The throughput_history is being
pushed twice when both worker metrics and Prometheus metrics are present; modify
the update logic so that when Prometheus metrics fetch (metrics) is Ok and
parse_request_count yields an rps, you treat rps as the sole throughput source
and do not push total_throughput into s.throughput_history. Concretely, in the
block that currently pushes total_throughput (using total_throughput and
has_worker_metrics), guard that push so it only runs when metrics is
Err/unavailable (or when prev_request_count yields None), and keep the existing
push of rps (from parse_request_count / current_count / delta) as the primary
update for s.throughput_history and s.requests_per_sec_history.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tui/src/chat.rs`:
- Around line 198-237: The SSE parser in process_sse_stream currently only
extracts choices[0].delta.content and treats EOF as success; update it to detect
SSE event types and surface mid-stream errors: while parsing lines in
process_sse_stream, check for "event: " lines (e.g., event: error) and when an
"error" event is seen, read the following "data: " payload, parse it for an
error message (or send the raw data) and send it over tx as an error (e.g.,
prefix with [ERROR]) and then return without emitting [DONE]; also when a "data:
" payload parses to a JSON object containing an "error" field, surface that as
an error immediately instead of ignoring it. Use the existing buffer/line
handling and tx sending flow in process_sse_stream and avoid emitting [DONE]
after an error.

In `@tui/src/main.rs`:
- Around line 108-116: The readiness loop currently times out after 30s; change
the timeout to 120s so auto-start waits the full intended period. Replace
Duration::from_secs(30) with Duration::from_secs(120) in the deadline
calculation (the code around tokio::time::Instant::now() +
tokio::time::Duration::from_secs(...)), and update the tracing::warn message if
desired to reflect 120s so Gateway readiness polling for auto-start matches the
README/PR intent.

In `@tui/src/types.rs`:
- Around line 162-187: The Vllm branch (Self::Vllm) builds a gRPC entrypoint
("vllm.entrypoints.grpc_server") that requires vLLM >= 0.14.0 when grpc is true;
add a runtime version check before constructing the command (or explicitly
document the requirement) — e.g., when handling Self::Vllm and grpc == true,
query the installed vllm version from the Python environment and error/abort
with a clear message if it's < 0.14.0, otherwise proceed to set entrypoint and
args (variables: entrypoint, grpc, model_id, tp, port) so the launcher fails
fast with a clear diagnostic instead of attempting to start an unsupported
entrypoint.

---

Duplicate comments:
In `@tui/src/app.rs`:
- Around line 633-635: In handle_chat_key, the KeyCode::Char('q') branch sets
self.should_quit = true unconditionally when !self.chat_streaming, preventing
typing 'q' into the chat; change the condition to mirror the other
view-switching keys by requiring both !self.chat_streaming and
self.chat_input.is_empty() before setting self.should_quit, so that 'q' only
quits when the chat input is empty and not streaming.
- Around line 1281-1288: On registration failure in the Err(e) branch after
calling add_worker(), ensure you roll back the spawned worker: remove its entry
from worker_children, terminate/kill the child process (or send shutdown) and
release any claimed GPUs (the same logic used when stopping workers), then log
and set status as before; implement this cleanup in the Err(e) block that
currently calls add_log and set_status so the child process doesn't keep running
and GPUs remain freed.
- Around line 758-774: The current loop over workers (workers.workers) treats
any external worker as requiring gpt-5.4-prefixed models by filtering with
m.id.starts_with("gpt-5.4"), which drops valid provider model ids; change the
logic so that if a worker has an empty models list (w.models.is_empty()) you add
the provider-default placeholder "gpt-5.4-nano" once, but otherwise accept and
push every reported model id (m.id) for that worker without applying the
starts_with filter; specifically remove the branch that does if is_external &&
!m.id.starts_with("gpt-5.4") { continue } and keep the existing duplicate-check
on models.contains before pushing m.id, and ensure the placeholder insertion
still guards against duplicates.
- Around line 534-556: The toggle-health handler (and the ToggleHealthCheck menu
action) always sends disable_health_check: Some(true), making it one-way; change
the logic to read the worker's current disable_health_check state (e.g., fetch
the worker via self.client.get_worker(&id).await or from the in-memory worker
record) and send disable_health_check: Some(!current_value) when building
openai_protocol::worker::WorkerUpdateRequest so the update toggles between
enabled/disabled; update both the "toggle-health" match arm and the
ToggleHealthCheck action to use the same toggle-by-current-state approach and
preserve other fields as before.
- Around line 1156-1168: The current ephemeral-port allocation uses
std::net::TcpListener to pick a free port then immediately drops it (the match
that assigns port), creating a TOCTOU race; instead either (A) stop pre-binding
and pass port 0 directly to the worker runtime so the worker itself binds and
reports the actual bound port, or (B) add robust retry logic around worker bind
attempts: if you must keep the existing TcpListener pattern (the code that
assigns port and calls self.set_status on error), modify the worker spawn/bind
path to accept port 0 or detect binding failure and retry N times with a small
backoff before reporting failure via self.set_status; reference the current port
variable and the TcpListener bind block when implementing this change so the
worker binding and reporting are atomic and race-free.

In `@tui/src/event.rs`:
- Around line 31-32: Change the comment prefix from "Safety:" to "INVARIANT:"
for the safe-code assumption in the event reader description so it follows
repository convention; update the comment that currently reads "// Safety:
fire-and-forget event reader loop that runs for the app's lifetime" to use
"INVARIANT:" instead (this is right above the
#[expect(clippy::disallowed_methods)] attribute in event.rs and refers to the
event reader loop assumption).
- Line 1: The event forwarder is emitting all crossterm Event::Key variants
causing duplicate actions on Repeat/Release; update the code that matches
Event::Key (in tui/src/event.rs) to only enqueue/forward when the contained
KeyEvent has kind == KeyEventKind::Press (i.e., filter Event::Key(k) by k.kind
== KeyEventKind::Press) so the rest of the app receives only key presses.

In `@tui/src/main.rs`:
- Around line 158-171: The terminal setup must be protected by an RAII guard
instead of relying only on the panic hook: create a small guard type (e.g.
TerminalGuard) whose constructor calls enable_raw_mode() and
EnterAlternateScreen and whose Drop impl calls LeaveAlternateScreen and
disable_raw_mode(), then instantiate this guard before creating the
CrosstermBackend/Terminal (symbols: enable_raw_mode, disable_raw_mode,
EnterAlternateScreen, LeaveAlternateScreen, CrosstermBackend::new,
Terminal::new) so any early-return or error will run Drop and restore the
terminal; keep or call std::panic::set_hook as before, but remove cleanup logic
from the hook since the guard handles restoration.
- Around line 197-199: The current extract_port function incorrectly slices on
':' and misparses paths like "http://localhost:31000/health"; change
extract_port to actually parse the URL (use the url::Url parser) and return the
port via Url::port() or Url::port_or_known_default() to get the numeric port
correctly; update Cargo.toml to include the url crate if missing and replace the
rsplit-based logic in extract_port with a proper Url::parse(url) flow that
returns the parsed port as Option<u16>.

In `@tui/src/state.rs`:
- Around line 450-466: The subtraction cur_sum - prev_sum in the avg latency
computation can be negative after a Prometheus counter reset; in
parse_duration_stats usage where you compute delta_sum and delta_count (using
s.prev_duration_sum and s.prev_duration_count) clamp delta_sum to non-negative
before computing avg_latency (e.g., replace raw subtraction with a non-negative
max) so you never push negative values into s.avg_latency_history (respect
existing delta_count handling and SPARKLINE_CAP).
- Around line 395-427: The throughput_history is being pushed twice when both
worker metrics and Prometheus metrics are present; modify the update logic so
that when Prometheus metrics fetch (metrics) is Ok and parse_request_count
yields an rps, you treat rps as the sole throughput source and do not push
total_throughput into s.throughput_history. Concretely, in the block that
currently pushes total_throughput (using total_throughput and
has_worker_metrics), guard that push so it only runs when metrics is
Err/unavailable (or when prev_request_count yields None), and keep the existing
push of rps (from parse_request_count / current_count / delta) as the primary
update for s.throughput_history and s.requests_per_sec_history.

In `@tui/src/ui/action_menu.rs`:
- Around line 145-167: The code leaks heap strings via Box::leak in
AddMenuState::SelectModel; replace those leaks by keeping owned Strings (or Cow)
for the index labels so they live for the duration of the render and can be
borrowed when building refs. Concretely, change items to Vec<(String, String,
String)> (build index labels with format!("{}", i+1) and the custom label with
format!("{}", presets.len()+1)), push those Strings into items, then create refs
by borrowing with .as_str() (e.g. .map(|(n,l,d)| (n.as_str(), l.as_str(),
d.as_str())) ) before calling render_menu; this removes Box::leak while keeping
the same render_menu call pattern.

In `@tui/src/ui/chat.rs`:
- Around line 22-37: The title shows an incorrect path for ChatEndpoint::Chat;
update the title formatting in chat UI to display the actual POST path used by
the client ("/v1/chat/completions") instead of "/v1/chat" when app.chat_endpoint
is Chat; locate the code that builds the title (uses app.chat_endpoint,
ChatEndpoint::Chat and app.chat_endpoint.label()) and either change the label()
call for Chat to return "chat/completions" or special-case Chat in this
formatting to insert "/v1/chat/completions" so the displayed path matches the
request path used by tui/src/chat.rs.
- Around line 152-164: The scroll math is using byte length via
span.content.len(), which miscalculates display width for emoji/CJK; change the
width calculation in the total_lines mapping to use a display-width function
(e.g. UnicodeWidthStr::width) on each span's content instead of len(), keep the
max(1) and ceil division logic, and add the necessary import for the
unicode-width helper so total_lines reflects visual columns rather than byte
count.

In `@tui/src/ui/detail.rs`:
- Around line 32-34: Replace the "Safety:" note above the unwrap with the
repository's invariant marker: change the comment before the call to
app.state.read().unwrap() to start with "INVARIANT:" (e.g., "INVARIANT: RwLock
is not poisoned — no panics while holding the lock") so the assumption is
documented as a safe-code invariant rather than a SAFETY explanation; leave the
#[expect(clippy::unwrap_used)] and the unwrap call unchanged.
- Around line 225-230: truncate_str currently slices bytes which can split UTF-8
multibyte characters; update truncate_str to operate on char boundaries: check
if s.chars().count() <= max then return s.to_string(), otherwise build the
prefix with s.chars().take(max.saturating_sub(1)).collect::<String>() and append
the ellipsis (e.g., prefix + "…"); handle the max == 0 case by returning "…" (or
an empty string plus ellipsis) so no byte-slice is ever used and multibyte
characters are preserved.
- Around line 130-143: The UI is incorrectly using worker.is_healthy to
represent circuit-breaker state (cb_label/cb_color and the "Circuit: " span),
causing healthy-but-open breakers to display as "closed"; update this block to
read the actual breaker signal (e.g., a field like worker.circuit_open,
worker.breaker_state, or similar) when available and map that to labels/colors,
and if no breaker state is present render "unknown" instead; also keep the
existing health-only semantics (healthy_only) intact so the 503 path still
applies for healthy workers with an open breaker.

In `@tui/src/ui/footer.rs`:
- Line 25: The footer currently advertises only "1-5" in the normal-mode hint
sets, but the UI exposes seven views; update the two hint label calls so the
footer advertises all seven views. Locate the hint("1-5", "view") invocations in
tui/src/ui/footer.rs (the two occurrences around the normal-mode hint blocks)
and change the displayed range to "1-7" (e.g., hint("1-7", "view")) so both
normal-mode hint sets advertise all seven views including Traffic and Mesh.

In `@tui/src/ui/logs.rs`:
- Around line 163-190: render_file_log currently reads and parses the entire
file on every frame (via std::fs::read_to_string and content.lines().collect),
which blocks the UI; move all file I/O and tailing into the background poller
and have render_file_log only render a cached tail. Specifically: add a cached
tail field (e.g., App.cached_log_tail: Vec<Line> or String) updated by the
poller, change the poller to read the file, compute the last N lines
(max_lines=500), and store the result into that cache using a thread-safe
mechanism (Arc<Mutex<...>> or a channel update on App), and modify
render_file_log to skip read_to_string and instead render from
App.cached_log_tail (still using the same Block/Paragraph rendering and the
existing title/label logic).

In `@tui/src/ui/models.rs`:
- Line 18: Header text "OWNER" in the table (created via Row::new in the header
variable) does not match the rendered cell value which uses m.display_name;
change one to match the other by either renaming the header to "NAME" (or "OWNER
/ NAME") where header is built in Row::new, or update the cell renderer to
display the actual owner field (e.g., replace m.display_name with m.owner or
m.owner_id) in the code that builds the row cells around where m is used (lines
rendering the row, e.g., the code around m.display_name). Ensure the header
label and the value source are consistent across the table rendering.

In `@tui/src/ui/pulse.rs`:
- Around line 376-389: The compact throughput panel is wrongly using
state.throughput_history (and showing "No data" when tok/s history is empty) but
should use the request-rate history; update the panel logic in pulse.rs to check
state.request_rate_history for emptiness, use
state.request_rate_history.back().copied().unwrap_or(0.0) for latest, and
replace any uses of state.throughput_history in this panel (rendering the
Paragraph and any sparkline) with state.request_rate_history so the label
"req/s" matches the actual data shown.
- Around line 21-38: The narrow-layout branch for width < 80 omits the
request-stats panel (latency/connections/in-flight); update the branch that
builds constraints and renders rows so it includes a Constraint and a call to
the request-stats renderer (e.g., add Constraint::Fill(1) to the constraints Vec
and call render_request_stats(f, &state, rows[i]) in sequence before returning),
making sure to account for has_node_panel when computing the row index similarly
to render_worker_health, render_node_status, and render_throughput_compact.

In `@tui/src/ui/stats_bar.rs`:
- Around line 79-85: The health_text branch logic treats a connected gateway
with total == 0 as "all healthy"; change health_text (used where
state.connected, unhealthy, total are referenced) so that when state.connected
is true and total == 0 it returns the neutral/no-workers variant (e.g., "--" or
"no workers" with theme::TEXT_MUTED) before checking unhealthy == 0; reorder the
conditions accordingly so the total==0 case is handled first while preserving
the existing GREEN for actual healthy workers and RED for unhealthy cases.

In `@tui/src/ui/tabs.rs`:
- Around line 14-18: The tab numbering currently uses enumerate() and i + 1 when
building tabs from View::all(), which duplicates View's own numbering logic;
update the closure that constructs the tab label (the block producing num =
format!("{}", i + 1)) to use view.index() as the source of the tab number
instead (call view.index() and format that value), removing reliance on
enumerate() for numbering so the rendered shortcut stays consistent with
View::index() across the codebase.

In `@tui/src/ui/workers.rs`:
- Around line 299-304: The truncate(s: &str, max: usize) function currently
slices by bytes which can panic on UTF-8 boundaries; change it to treat max as a
character limit by operating on chars rather than byte indices (e.g., iterate
s.chars() and collect up to max characters, appending the ellipsis when the
original string has more than max chars). Update truncate to check character
count, return s.to_string() when length <= max, otherwise build a new String
from the first max-1 characters (or first max and then replace last with
ellipsis per existing behavior) using char iteration so all slicing is UTF-8
safe; reference function name truncate in this file.
- Around line 269-283: The detail pane currently uses the raw app.selected_index
which can be out-of-sync with the clamped selection used for the table; after
you call table_state.select(...) read the clamped index from
table_state.selected() and use that to look up the worker for
detail::render_detail instead of app.selected_index. Concretely, after building
table_state use something like if let Some(clamped) = table_state.selected() {
if let Some(worker) = filtered.get(clamped) { detail::render_detail(f, app,
worker, detail_area); } } so the detail pane always follows the table's actual
highlighted row.
- Around line 326-346: The current code drops extra load entries by using
details.loads.first() and also fails to use worker_rps for plain "http://"
fallbacks; change the logic in the worker-load formatting block to (1) aggregate
across all entries in details.loads (e.g., total_running =
sum(load.num_running_reqs) and token_usage = max(load.token_usage) or a sensible
aggregate) instead of details.loads.first(), and (2) move/extend the
rps-fallback to cover plain "http://" (or, more generally, if worker_rps
contains an entry use that) so that when /get_loads is missing you return
(format!("{rps:.1} r/s"), "N/A".to_string()) rather than ("0", "0.0%").
Reference symbols: loads.get(worker_url), wl.details, details.loads,
worker_url.starts_with(...), and worker_rps.get(worker_url).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7a504163-0ef8-4bf8-b21d-b565d83a73ed

📥 Commits

Reviewing files that changed from the base of the PR and between 50b7078 and 95a1ee0.

⛔ Files ignored due to path filters (2)
  • tui/assets/add-worker.gif is excluded by !**/*.gif
  • tui/assets/tui-demo.gif is excluded by !**/*.gif
📒 Files selected for processing (28)
  • .pre-commit-config.yaml
  • Cargo.toml
  • tui/Cargo.toml
  • tui/README.md
  • tui/src/app.rs
  • tui/src/chat.rs
  • tui/src/client.rs
  • tui/src/event.rs
  • tui/src/lib.rs
  • tui/src/main.rs
  • tui/src/state.rs
  • tui/src/types.rs
  • tui/src/ui/action_menu.rs
  • tui/src/ui/chat.rs
  • tui/src/ui/detail.rs
  • tui/src/ui/dialog.rs
  • tui/src/ui/filter.rs
  • tui/src/ui/footer.rs
  • tui/src/ui/help.rs
  • tui/src/ui/logs.rs
  • tui/src/ui/mod.rs
  • tui/src/ui/models.rs
  • tui/src/ui/pulse.rs
  • tui/src/ui/sparkline.rs
  • tui/src/ui/stats_bar.rs
  • tui/src/ui/tabs.rs
  • tui/src/ui/theme.rs
  • tui/src/ui/workers.rs

Comment thread tui/src/chat.rs
Comment thread tui/src/main.rs
Comment thread tui/src/types.rs
@key4ng
key4ng force-pushed the keyang/tui-redesign branch from 262575f to d4bf248 Compare March 23, 2026 20:00

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

♻️ Duplicate comments (6)
tui/src/event.rs (1)

31-32: 🧹 Nitpick | 🔵 Trivial

Use INVARIANT: instead of Safety: for non-unsafe code.

The comment on Line 31 uses "Safety:" but this isn't an unsafe block. Per repository conventions, use INVARIANT: to document assumptions in safe code.

📝 Proposed fix
-        // Safety: fire-and-forget event reader loop that runs for the app's lifetime
+        // INVARIANT: fire-and-forget event reader loop that runs for the app's lifetime

Based on learnings: "In Rust code across the repository, use the marker INVARIANT: to document assumptions in safe code. Reserve SAFETY: for explaining why unsafe blocks are sound."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/event.rs` around lines 31 - 32, The comment above the event reader
loop currently uses "Safety:" but this is safe code; update the doc comment to
use "INVARIANT:" instead to follow repository conventions—locate the comment
immediately preceding the #[expect(clippy::disallowed_methods)] attribute and
the fire-and-forget event reader loop and replace "Safety:" with "INVARIANT:" so
the assumption is documented correctly for the safe code path.
tui/src/ui/workers.rs (1)

345-356: ⚠️ Potential issue | 🟡 Minor

Plain http:// workers don't fall back to Prometheus RPS.

Lines 346-354 provide worker_rps fallback for grpc:// and https:// workers when /get_loads data is unavailable, but plain http:// workers fall through to line 355 returning ("0", "0.0%"). If local sglang/vllm workers use http:// URLs, they'd show zero load when /get_loads is temporarily unavailable instead of the Prometheus-derived request rate.

🩹 Proposed fix
     // External workers
     if worker_url.starts_with("https://") {
         let rps = worker_rps.get(worker_url).copied().unwrap_or(0.0);
         return (format!("{rps:.1} r/s"), "N/A".to_string());
     }
-    ("0".to_string(), "0.0%".to_string())
+    // Fallback for http:// and other schemes: use Prometheus RPS if available
+    if worker_url.starts_with("http://") {
+        let rps = worker_rps.get(worker_url).copied().unwrap_or(0.0);
+        return (format!("{rps:.1} r/s"), "N/A".to_string());
+    }
+    ("--".to_string(), "--".to_string())
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/workers.rs` around lines 345 - 356, The code only checks
worker_url prefixes "grpc://" and "https://" for Prometheus RPS fallback,
causing plain "http://" workers to return ("0","0.0%"); update the logic that
builds the per-worker load tuple (the block using worker_url and worker_rps) to
also handle "http://" (either add an if branch for
worker_url.starts_with("http://") mirroring the grpc/https branches or collapse
into a single branch that checks any scheme and uses
worker_rps.get(worker_url).copied().unwrap_or(0.0) to format "{rps:.1} r/s" and
"N/A"), referencing the existing worker_url and worker_rps variables to locate
and change the code.
tui/src/main.rs (2)

108-116: ⚠️ Potential issue | 🟡 Minor

Readiness timeout is 30s but documentation mentions 120s.

The PR objectives/README describe readiness polling for up to 120s, but this code times out after 30s. For slow local model loading, 30s may be insufficient.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/main.rs` around lines 108 - 116, The readiness timeout is set to 30s
but the docs state 120s; update the timeout in the loop where deadline is
computed (the tokio::time::Instant::now() + tokio::time::Duration::from_secs(30)
assignment that defines deadline) to 120 seconds (or replace the literal with a
named constant like READYNESS_TIMEOUT_SECS used by the readiness polling logic)
so the loop waits up to 120s before warning and breaking.

208-211: ⚠️ Potential issue | 🟠 Major

extract_port() misparses URLs that include a path.

For inputs like http://localhost:31000/health, rsplit(':') yields 31000/health, so the parse fails and auto-start falls back to the default port. Consider proper URL parsing.

Proposed fix
 /// Extract port from a URL like "http://localhost:30000".
 fn extract_port(url: &str) -> Option<u16> {
-    url.rsplit(':').next()?.trim_end_matches('/').parse().ok()
+    // Strip scheme, then extract host:port portion before any path
+    let without_scheme = url
+        .trim_start_matches("http://")
+        .trim_start_matches("https://");
+    let host_port = without_scheme.split('/').next()?;
+    host_port.rsplit(':').next()?.parse().ok()
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/main.rs` around lines 208 - 211, The current extract_port() uses
rsplit(':') and fails on URLs with paths (e.g. "http://host:31000/health");
replace that ad-hoc parsing with a proper URL parse: call url::Url::parse(url)
(or prepend "http://" if scheme may be missing), then return the port via
url.port().map(|p| p) or url.port_or_known_default() wrapped as Option<u16>, and
handle parse errors by returning None; update the extract_port function to use
url::Url parsing and error-safe extraction instead of rsplit.
tui/src/ui/detail.rs (1)

32-34: 🧹 Nitpick | 🔵 Trivial

Use INVARIANT: instead of Safety: for non-unsafe code.

Line 32 uses "Safety:" but this is a safe RwLock::read().unwrap() call, not an unsafe block. Per repository conventions, use INVARIANT: for documenting assumptions in safe code.

Proposed fix
-    // Safety: RwLock is not poisoned — no panics while holding the lock
+    // INVARIANT: RwLock is not poisoned — no panics while holding the lock

Based on learnings: "In Rust code across the repository, use the marker INVARIANT: to document assumptions in safe code. Reserve SAFETY: for explaining why unsafe blocks are sound."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/detail.rs` around lines 32 - 34, The comment above the RwLock read
should use the repository convention INVARIANT: instead of Safety: because this
is documenting an assumption in safe code; update the doc comment that precedes
#[expect(clippy::unwrap_used)] and the line let state =
app.state.read().unwrap() to start with "INVARIANT:" (e.g., "INVARIANT: RwLock
is not poisoned — no panics while holding the lock") so it matches the project's
guidance for non-unsafe code.
tui/src/app.rs (1)

451-466: ⚠️ Potential issue | 🟠 Major

:delete <id> uses wrong worker URL for cleanup and doesn't kill backend.

The command deletes the worker by the provided ID argument, but then calls selected_worker_url() which returns the currently selected worker's URL—not the worker being deleted. This causes:

  1. GPU claims for the wrong worker get released
  2. The deleted worker's backend process is never killed
  3. The deleted worker's GPU claims are never released

The interactive delete (lines 829-882) correctly uses the confirmed worker's URL and kills the backend. This command should do the same.

Proposed fix: Look up worker URL by ID before deletion
 Some("delete") => {
     if let Some(id) = parts.get(1) {
         let id = id.trim().to_string();
+        // Look up worker URL before deletion
+        let worker_url = {
+            #[expect(clippy::unwrap_used)]
+            let state = self.state.read().unwrap();
+            state.workers.as_ref()
+                .and_then(|wl| wl.workers.iter().find(|w| w.id == id))
+                .map(|w| w.url.clone())
+        };
         match self.client.delete_worker(&id).await {
             Ok(_) => {
-                // Also clean up spawned process and GPU claims (like interactive delete)
-                let worker_url = self.selected_worker_url().unwrap_or_default();
-                self.claimed_gpus.remove(&worker_url);
+                if let Some(url) = worker_url {
+                    // Release claimed GPUs
+                    if self.claimed_gpus.remove(&url).is_some() {
+                        self.add_log(LogLevel::Info, &format!("Released GPU claim for {url}"));
+                    }
+                    // Kill backend process if spawned by TUI
+                    if let Some(port) = url.rsplit(':').next() {
+                        let port = port.trim_matches('/').to_string();
+                        let match_str = format!("port {port}");
+                        if let Some(pos) = self.worker_children.iter().position(|(d, _)| d.contains(&match_str)) {
+                            let (desc, mut child) = self.worker_children.remove(pos);
+                            let _ = child.kill().await;
+                            self.add_log(LogLevel::Info, &format!("Killed backend: {desc}"));
+                        }
+                    }
+                }
                 self.set_status(format!("Worker {id} deleted"));
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/app.rs` around lines 451 - 466, When handling the "delete" command,
don't call selected_worker_url(); instead look up the worker's URL by the
provided id (the same lookup used in the interactive delete flow), store it in a
local worker_url before calling self.client.delete_worker(&id).await, then after
a successful delete call the same backend-kill routine used by the interactive
delete to terminate the worker process and remove GPU claims from
self.claimed_gpus for that worker_url, and finally call self.set_status with the
id; mirror the interactive delete's order and method calls so the deleted
worker's process is killed and its GPU claims are released rather than touching
the currently-selected worker.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tui/src/app.rs`:
- Around line 540-565: The toggle currently uses worker.is_healthy to set
disable_health_check, which is incorrect; change the logic in the
"toggle-health" branch to read the worker's current health.disable_health_check
(e.g., via selected_worker() -> worker.health.disable_health_check), compute
new_disable = !current_disable (treat missing value as false), build the
WorkerUpdateRequest with health.disable_health_check: Some(new_disable) and
update action text to reflect actual change (enabled/disabled based on
new_disable), then call self.client.update_worker(&worker.id, &update) and
set_status as before.
- Around line 1360-1371: clamp_selection currently clamps selected_index against
the unfiltered workers.len(); change it to compute the visible/filtered count
using the same filtering logic the UI uses (the predicate used by
selected_worker or the function that produces the displayed list from
state.workers), then clamp selected_index to visible_count.saturating_sub(1) (or
set to 0 when visible_count==0) so the index is always valid for the displayed
list; update clamp_selection to read state, produce the filtered worker
slice/iterator, get its length, and use that length for the min() bound on
selected_index.

In `@tui/src/state.rs`:
- Around line 306-308: Replace the "Safety:" comment marker with "INVARIANT:"
for the safe RwLock usage to match repository convention; update the comment
above the state.write().unwrap() call (the RwLock usage where the code currently
reads "// Safety: RwLock is not poisoned in practice — no panics while holding
the lock") to use "INVARIANT:" and keep the same explanatory text about
non-poisoning and no panics while holding the lock.

In `@tui/src/ui/action_menu.rs`:
- Around line 249-255: The mask uses input.len() (byte length) causing incorrect
mask width for multi-byte characters; update the branch that constructs the
masked Span (the else-if using `masked` and
`Span::styled("*".repeat(input.len()), ...)`) to compute the character count
with `input.chars().count()` and repeat '*' that many times so the displayed
mask matches the visual character count (keep the other branches and
Style/theme::TEXT usage unchanged).

In `@tui/src/ui/logs.rs`:
- Around line 163-198: The render_file_log function currently seeks to len -
TAIL_BYTES which can land mid-UTF-8 sequence and produce a corrupted first line;
after the seek/read (the branch where len > TAIL_BYTES and file.read_to_string
populates buf) detect that we did a mid-file seek and drop the partial first
line by finding the first newline in buf and replacing buf with the substring
after that newline (or clearing buf if none found) before rendering; update the
code around TAIL_BYTES, the file.seek call, and the subsequent use of
buf/read_to_string to perform this safe-trim-of-first-line so the displayed tail
never begins with a partial UTF-8 character.

In `@tui/src/ui/models.rs`:
- Around line 12-14: Replace the misleading comment marker "Safety:" with the
repository convention "INVARIANT:" for the non-unsafe assumption above the
RwLock read; specifically update the comment before the expect attribute that
documents why app.state.read().unwrap() is safe to read (e.g., change "//
Safety: RwLock is not poisoned — no panics while holding the lock" to use
"INVARIANT:" and keep the rest of the text and the
#[expect(clippy::unwrap_used)] and the call to app.state.read().unwrap()
unchanged).

In `@tui/src/ui/pulse.rs`:
- Around line 242-254: shorten_gpu_name uses byte-slicing name[..12] which can
panic on UTF-8 boundaries; change it to perform char-based truncation similar to
truncate_str by taking the first 12 chars (e.g.,
name.chars().take(12).collect()) after applying the trim_start_matches calls,
and return that String instead of slicing bytes — update the shorten_gpu_name
function to use character iteration to safely truncate GPU names.
- Around line 13-15: Change the comment before the RwLock read to use the
repository convention for safe-code assumptions: replace the "Safety:" marker
with "INVARIANT:" for the `app.state.read().unwrap()` usage (the comment that
documents why unwrapping is safe for RwLock in `pulse.rs`), keeping the existing
#[expect(clippy::unwrap_used)] attribute intact.

In `@tui/src/ui/workers.rs`:
- Around line 23-25: Replace the comment annotating the assumption about the
RwLock with the repository convention: change the leading "Safety:" marker to
"INVARIANT:" for the non-unsafe code that reads the state; locate the read call
(app.state.read().unwrap()) and update the preceding comment from "Safety:
RwLock is not poisoned — no panics while holding the lock" to begin with
"INVARIANT:" while keeping the rest of the explanatory text and preserving the
#[expect(clippy::unwrap_used)] attribute.

---

Duplicate comments:
In `@tui/src/app.rs`:
- Around line 451-466: When handling the "delete" command, don't call
selected_worker_url(); instead look up the worker's URL by the provided id (the
same lookup used in the interactive delete flow), store it in a local worker_url
before calling self.client.delete_worker(&id).await, then after a successful
delete call the same backend-kill routine used by the interactive delete to
terminate the worker process and remove GPU claims from self.claimed_gpus for
that worker_url, and finally call self.set_status with the id; mirror the
interactive delete's order and method calls so the deleted worker's process is
killed and its GPU claims are released rather than touching the
currently-selected worker.

In `@tui/src/event.rs`:
- Around line 31-32: The comment above the event reader loop currently uses
"Safety:" but this is safe code; update the doc comment to use "INVARIANT:"
instead to follow repository conventions—locate the comment immediately
preceding the #[expect(clippy::disallowed_methods)] attribute and the
fire-and-forget event reader loop and replace "Safety:" with "INVARIANT:" so the
assumption is documented correctly for the safe code path.

In `@tui/src/main.rs`:
- Around line 108-116: The readiness timeout is set to 30s but the docs state
120s; update the timeout in the loop where deadline is computed (the
tokio::time::Instant::now() + tokio::time::Duration::from_secs(30) assignment
that defines deadline) to 120 seconds (or replace the literal with a named
constant like READYNESS_TIMEOUT_SECS used by the readiness polling logic) so the
loop waits up to 120s before warning and breaking.
- Around line 208-211: The current extract_port() uses rsplit(':') and fails on
URLs with paths (e.g. "http://host:31000/health"); replace that ad-hoc parsing
with a proper URL parse: call url::Url::parse(url) (or prepend "http://" if
scheme may be missing), then return the port via url.port().map(|p| p) or
url.port_or_known_default() wrapped as Option<u16>, and handle parse errors by
returning None; update the extract_port function to use url::Url parsing and
error-safe extraction instead of rsplit.

In `@tui/src/ui/detail.rs`:
- Around line 32-34: The comment above the RwLock read should use the repository
convention INVARIANT: instead of Safety: because this is documenting an
assumption in safe code; update the doc comment that precedes
#[expect(clippy::unwrap_used)] and the line let state =
app.state.read().unwrap() to start with "INVARIANT:" (e.g., "INVARIANT: RwLock
is not poisoned — no panics while holding the lock") so it matches the project's
guidance for non-unsafe code.

In `@tui/src/ui/workers.rs`:
- Around line 345-356: The code only checks worker_url prefixes "grpc://" and
"https://" for Prometheus RPS fallback, causing plain "http://" workers to
return ("0","0.0%"); update the logic that builds the per-worker load tuple (the
block using worker_url and worker_rps) to also handle "http://" (either add an
if branch for worker_url.starts_with("http://") mirroring the grpc/https
branches or collapse into a single branch that checks any scheme and uses
worker_rps.get(worker_url).copied().unwrap_or(0.0) to format "{rps:.1} r/s" and
"N/A"), referencing the existing worker_url and worker_rps variables to locate
and change the code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a61bbe20-d95f-4d53-b552-03419d395848

📥 Commits

Reviewing files that changed from the base of the PR and between 95a1ee0 and 262575f.

📒 Files selected for processing (14)
  • tui/src/app.rs
  • tui/src/event.rs
  • tui/src/main.rs
  • tui/src/state.rs
  • tui/src/ui/action_menu.rs
  • tui/src/ui/detail.rs
  • tui/src/ui/footer.rs
  • tui/src/ui/help.rs
  • tui/src/ui/logs.rs
  • tui/src/ui/models.rs
  • tui/src/ui/pulse.rs
  • tui/src/ui/stats_bar.rs
  • tui/src/ui/tabs.rs
  • tui/src/ui/workers.rs

Comment thread tui/src/app.rs
Comment thread tui/src/app.rs
Comment thread tui/src/state.rs
Comment thread tui/src/ui/action_menu.rs
Comment thread tui/src/ui/logs.rs
Comment thread tui/src/ui/models.rs
Comment thread tui/src/ui/pulse.rs
Comment thread tui/src/ui/pulse.rs
Comment thread tui/src/ui/workers.rs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d4bf24818f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tui/src/app.rs Outdated
Comment thread tui/src/state.rs
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (8)
tui/src/ui/pulse.rs (2)

379-391: ⚠️ Potential issue | 🟠 Major

Read request-rate history in the compact throughput view.

Lines 379-387 still pull from throughput_history while the label says req/s, and Lines 379-384 report “No data” even when requests_per_sec_history has samples. Narrow and 80–99 column layouts will show the wrong metric.

Suggested fix
-    if state.throughput_history.is_empty() {
+    if state.requests_per_sec_history.is_empty() {
         f.render_widget(
             Paragraph::new(Line::styled("No data", theme::label())),
             inner,
         );
         return;
     }
 
-    let latest = state.throughput_history.back().copied().unwrap_or(0.0);
+    let latest = state.requests_per_sec_history.back().copied().unwrap_or(0.0);
     f.render_widget(
         Paragraph::new(Line::from(vec![
             Span::styled("Latest: ", theme::label()),
             Span::styled(format!("{latest:.1} req/s"), theme::text().fg(theme::GREEN)),
         ])),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/pulse.rs` around lines 379 - 391, The compact throughput view is
reading from state.throughput_history but should use
state.requests_per_sec_history; update the empty-check and the latest-value
extraction in the block that renders the "Latest: ... req/s" paragraph to use
requests_per_sec_history (replace uses of throughput_history.back()/is_empty()
with requests_per_sec_history.back()/is_empty()), so the label and displayed
metric match the actual request-rate history shown by the compact view.

248-250: ⚠️ Potential issue | 🟡 Minor

Avoid byte slicing in shorten_gpu_name().

Line 250 slices a UTF-8 string by byte index, so a non-ASCII GPU name can panic the render path. This is low-probability with current NVIDIA names, but it is still a runtime panic in a hot UI path.

Suggested fix
     // Truncate if too long
-    if name.len() > 12 {
-        name[..12].to_string()
+    if name.chars().count() > 12 {
+        name.chars().take(12).collect()
     } else {
         name.to_string()
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/pulse.rs` around lines 248 - 250, The function shorten_gpu_name()
currently truncates the input string by byte slicing (name[..12].to_string()),
which can panic on UTF-8 multi-byte characters; replace that byte-slice logic
with a UTF-8 safe truncation: iterate over name.chars() and take the first N
characters (or use the unicode-segmentation crate and take grapheme clusters if
you need to preserve visible characters) and collect into a String so the
function returns a safely truncated GPU name without panicking.
tui/src/ui/workers.rs (1)

345-355: ⚠️ Potential issue | 🟠 Major

Use the existing Prometheus fallback for plain http:// workers too.

If /get_loads is missing, Line 355 falls back to hard-coded zeros for plain http:// URLs instead of the worker_rps map already passed in. That makes locally spawned HTTP workers look idle whenever the load endpoint is unavailable.

Suggested fix
-    // gRPC/local workers: show req/s from Prometheus per-worker counts
-    if worker_url.starts_with("grpc://") {
-        let rps = worker_rps.get(worker_url).copied().unwrap_or(0.0);
-        return (format!("{rps:.1} r/s"), "N/A".to_string());
-    }
-    // External workers
-    if worker_url.starts_with("https://") {
+    // Fallback: show req/s from Prometheus when /get_loads is unavailable
+    if worker_url.starts_with("grpc://")
+        || worker_url.starts_with("http://")
+        || worker_url.starts_with("https://")
+    {
         let rps = worker_rps.get(worker_url).copied().unwrap_or(0.0);
         return (format!("{rps:.1} r/s"), "N/A".to_string());
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/workers.rs` around lines 345 - 355, The code currently returns
hard-coded zeros for plain http:// workers; update the branch handling so that
URLs starting with "http://" use the same Prometheus fallback as the grpc and
https branches: read rps from the worker_rps map
(worker_rps.get(worker_url).copied().unwrap_or(0.0)), format it as "{rps:.1}
r/s" and return "N/A" for the second value, replacing the final ("0","0.0%")
result for http:// cases; locate the block using the variables worker_url and
worker_rps in workers.rs and add a starts_with("http://") branch or broaden the
existing condition accordingly.
tui/src/main.rs (1)

208-210: ⚠️ Potential issue | 🟠 Major

Parse the port from the URL instead of splitting on :.

Line 210 misreads URLs with paths like http://localhost:31000/health, so auto-start falls back to 30000/29000 and can launch on the wrong socket.

Suggested fix
 /// Extract port from a URL like "http://localhost:30000".
 fn extract_port(url: &str) -> Option<u16> {
-    url.rsplit(':').next()?.trim_end_matches('/').parse().ok()
+    reqwest::Url::parse(url).ok()?.port()
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/main.rs` around lines 208 - 210, The extract_port function
incorrectly splits on ':' and mis-parses URLs with paths (e.g.,
"http://localhost:31000/health"); update extract_port to parse the input as a
URL and return the explicit port component instead of string-splitting: call
Url::parse(url) (from the url crate) and use Url::port() (or
port_or_known_default() if you want defaults) to extract the u16 port, handling
parse errors by returning None; update the function signature/existing callers
in extract_port to reflect this change.
tui/src/ui/logs.rs (1)

167-178: ⚠️ Potential issue | 🟠 Major

Make tail reads UTF-8-safe.

Line 172 can seek into the middle of a multibyte character. When that happens, Line 175 clears the whole buffer on decode failure, so the Logs tab goes blank; even successful mid-file reads can start with a partial first line.

Suggested fix
         Ok(mut file) => {
             // Read at most the last 64KB to avoid blocking on large files
             const TAIL_BYTES: u64 = 64 * 1024;
             let len = file.metadata().map(|m| m.len()).unwrap_or(0);
+            let did_seek = len > TAIL_BYTES;
             if len > TAIL_BYTES {
                 let _ = file.seek(SeekFrom::Start(len - TAIL_BYTES));
             }
-            let mut buf = String::new();
-            if file.read_to_string(&mut buf).is_err() {
+            let mut buf = Vec::new();
+            if file.read_to_end(&mut buf).is_err() {
                 buf.clear();
             }
-            buf
+            let text = String::from_utf8_lossy(&buf).into_owned();
+            if did_seek {
+                text.split_once('\n')
+                    .map(|(_, rest)| rest.to_string())
+                    .unwrap_or_default()
+            } else {
+                text
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/ui/logs.rs` around lines 167 - 178, The tail read can seek into the
middle of a UTF-8 multibyte sequence and then drop the entire buffer on decode
failure; fix by reading raw bytes instead of directly into a String and then
decode safely: change the logic around file.read_to_string to read_to_end into a
Vec<u8> (keep TAIL_BYTES and the seek logic), then convert that byte buffer to a
String using String::from_utf8_lossy or std::str::from_utf8 with a fallback so
partial leading bytes don't cause a full clear—reference the
variables/operations file, TAIL_BYTES, the seek(SeekFrom::Start(...)) call, and
the read_to_* call when making the change.
tui/src/app.rs (3)

451-460: ⚠️ Potential issue | 🟠 Major

Make :delete reuse the same cleanup path as the confirm dialog.

This branch still only deletes the gateway record and drops claimed_gpus for the currently selected row. :delete <other-id> can therefore release the wrong claim, and locally spawned backends in worker_children keep running. Route the command through the same helper used by handle_delete_confirm(), or resolve the worker URL/process by the provided id before cleaning up.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/app.rs` around lines 451 - 460, The current ":delete" branch calls
self.client.delete_worker(&id) and only removes claimed_gpus for the currently
selected row, which can release the wrong GPU and leave worker_children running;
fix by reusing the same cleanup path as handle_delete_confirm(): after
successful delete_worker(&id).await, resolve the worker URL and any spawned
child process corresponding to that id (use the same lookup logic used in
handle_delete_confirm() to map id → worker_url and id → worker_children entry),
remove the correct entry from claimed_gpus and stop/cleanup the matching
worker_children, and call set_status with the id; alternatively, invoke the same
helper method that handle_delete_confirm() uses to perform the cleanup instead
of directly mutating claimed_gpus/worker_children.

403-408: ⚠️ Potential issue | 🟠 Major

Use one shared filtered-worker view for selection and actions.

selected_worker(), start_delete(), and clamp_selection() still disagree about what "visible workers" means: one matches runtime_type, one only matches id/url, and one ignores the filter entirely. Under an active filter this can leave selected_index out of range or make d resolve a different/no worker than the other actions. Extract one helper for the filtered list and reuse it everywhere, then re-clamp immediately after updating active_filter.

Also applies to: 807-825, 1126-1145, 1360-1368

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/app.rs` around lines 403 - 408, The codebase has inconsistent
definitions of "visible workers" across selected_worker(), start_delete(), and
clamp_selection(), causing out-of-range or wrong selections when active_filter
changes; extract a single helper (e.g., a method like filtered_workers() or
get_visible_workers()) that returns the list of workers filtered by the same
criteria (use runtime_type + id/url + active_filter logic you want) and replace
all ad-hoc filtering in selected_worker(), start_delete(), clamp_selection(),
and the other occurrences (around the noted ranges) to use that helper; after
updating active_filter (when you set self.active_filter based on
self.input_buffer and change self.input_mode) call clamp_selection() immediately
to re-clamp selected_index against the unified filtered list so selection
remains consistent.

540-559: ⚠️ Potential issue | 🟠 Major

Toggle health checks from the config flag, not is_healthy.

An actually unhealthy worker with health checks still enabled takes the disable = false branch here, so the "toggle" becomes a no-op and the TUI can never disable checks on that worker. Derive the next value from the worker's current disable_health_check setting instead of inferring it from liveness.

Also applies to: 918-944

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tui/src/app.rs` around lines 540 - 559, The toggle currently uses
worker.is_healthy; instead derive the next disable value from the worker's
current health.disable_health_check flag. Retrieve current_disabled via
worker.health.as_ref().and_then(|h| h.disable_health_check).unwrap_or(false) and
set disable = !current_disabled, then build the
openai_protocol::worker::WorkerUpdateRequest::health with that disable; apply
the same change to the other occurrence around the WorkerUpdateRequest
construction (the similar block mentioned at lines 918-944).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tui/src/main.rs`:
- Around line 97-101: The spawned gateway process may survive early returns or
panics because the Child in _gateway_child isn't guaranteed to be killed; update
the Command invocation in main.rs (the tokio::process::Command::new("smg") chain
that creates child and assigns to _gateway_child) to call .kill_on_drop(true)
before .spawn() so the Child will be terminated automatically when dropped,
ensuring cleanup on all code paths including early returns and panic hook paths
that restore terminal state but don't explicitly kill the process.

In `@tui/src/state.rs`:
- Around line 412-448: The rate calculations (rps and token/s) in the metrics
handling (functions/variables: parse_request_count, parse_token_counts,
s.prev_request_count, s.prev_input_tokens, s.prev_output_tokens) incorrectly
assume each successful scrape is exactly interval_secs apart and produce spikes
when a scrape failed; fix by tracking the timestamp of the last successful
metrics scrape (add e.g. s.last_metrics_ts) and, when computing deltas for
rps/in_tps/out_tps, divide by the actual elapsed seconds between current scrape
and s.last_metrics_ts (or if you prefer simpler behavior, clear s.prev_*
counters when fetch_metrics() fails so you don't compute a rate on a
multi-interval gap); update both the request and token rate calculations (also
the worker_rps logic referenced at 477-486) to use the elapsed time and set
s.last_metrics_ts = now on successful parses.

---

Duplicate comments:
In `@tui/src/app.rs`:
- Around line 451-460: The current ":delete" branch calls
self.client.delete_worker(&id) and only removes claimed_gpus for the currently
selected row, which can release the wrong GPU and leave worker_children running;
fix by reusing the same cleanup path as handle_delete_confirm(): after
successful delete_worker(&id).await, resolve the worker URL and any spawned
child process corresponding to that id (use the same lookup logic used in
handle_delete_confirm() to map id → worker_url and id → worker_children entry),
remove the correct entry from claimed_gpus and stop/cleanup the matching
worker_children, and call set_status with the id; alternatively, invoke the same
helper method that handle_delete_confirm() uses to perform the cleanup instead
of directly mutating claimed_gpus/worker_children.
- Around line 403-408: The codebase has inconsistent definitions of "visible
workers" across selected_worker(), start_delete(), and clamp_selection(),
causing out-of-range or wrong selections when active_filter changes; extract a
single helper (e.g., a method like filtered_workers() or get_visible_workers())
that returns the list of workers filtered by the same criteria (use runtime_type
+ id/url + active_filter logic you want) and replace all ad-hoc filtering in
selected_worker(), start_delete(), clamp_selection(), and the other occurrences
(around the noted ranges) to use that helper; after updating active_filter (when
you set self.active_filter based on self.input_buffer and change
self.input_mode) call clamp_selection() immediately to re-clamp selected_index
against the unified filtered list so selection remains consistent.
- Around line 540-559: The toggle currently uses worker.is_healthy; instead
derive the next disable value from the worker's current
health.disable_health_check flag. Retrieve current_disabled via
worker.health.as_ref().and_then(|h| h.disable_health_check).unwrap_or(false) and
set disable = !current_disabled, then build the
openai_protocol::worker::WorkerUpdateRequest::health with that disable; apply
the same change to the other occurrence around the WorkerUpdateRequest
construction (the similar block mentioned at lines 918-944).

In `@tui/src/main.rs`:
- Around line 208-210: The extract_port function incorrectly splits on ':' and
mis-parses URLs with paths (e.g., "http://localhost:31000/health"); update
extract_port to parse the input as a URL and return the explicit port component
instead of string-splitting: call Url::parse(url) (from the url crate) and use
Url::port() (or port_or_known_default() if you want defaults) to extract the u16
port, handling parse errors by returning None; update the function
signature/existing callers in extract_port to reflect this change.

In `@tui/src/ui/logs.rs`:
- Around line 167-178: The tail read can seek into the middle of a UTF-8
multibyte sequence and then drop the entire buffer on decode failure; fix by
reading raw bytes instead of directly into a String and then decode safely:
change the logic around file.read_to_string to read_to_end into a Vec<u8> (keep
TAIL_BYTES and the seek logic), then convert that byte buffer to a String using
String::from_utf8_lossy or std::str::from_utf8 with a fallback so partial
leading bytes don't cause a full clear—reference the variables/operations file,
TAIL_BYTES, the seek(SeekFrom::Start(...)) call, and the read_to_* call when
making the change.

In `@tui/src/ui/pulse.rs`:
- Around line 379-391: The compact throughput view is reading from
state.throughput_history but should use state.requests_per_sec_history; update
the empty-check and the latest-value extraction in the block that renders the
"Latest: ... req/s" paragraph to use requests_per_sec_history (replace uses of
throughput_history.back()/is_empty() with
requests_per_sec_history.back()/is_empty()), so the label and displayed metric
match the actual request-rate history shown by the compact view.
- Around line 248-250: The function shorten_gpu_name() currently truncates the
input string by byte slicing (name[..12].to_string()), which can panic on UTF-8
multi-byte characters; replace that byte-slice logic with a UTF-8 safe
truncation: iterate over name.chars() and take the first N characters (or use
the unicode-segmentation crate and take grapheme clusters if you need to
preserve visible characters) and collect into a String so the function returns a
safely truncated GPU name without panicking.

In `@tui/src/ui/workers.rs`:
- Around line 345-355: The code currently returns hard-coded zeros for plain
http:// workers; update the branch handling so that URLs starting with "http://"
use the same Prometheus fallback as the grpc and https branches: read rps from
the worker_rps map (worker_rps.get(worker_url).copied().unwrap_or(0.0)), format
it as "{rps:.1} r/s" and return "N/A" for the second value, replacing the final
("0","0.0%") result for http:// cases; locate the block using the variables
worker_url and worker_rps in workers.rs and add a starts_with("http://") branch
or broaden the existing condition accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 598d00ab-6a02-4131-945d-621aa77ce91a

📥 Commits

Reviewing files that changed from the base of the PR and between 262575f and d4bf248.

📒 Files selected for processing (11)
  • tui/src/app.rs
  • tui/src/main.rs
  • tui/src/state.rs
  • tui/src/ui/footer.rs
  • tui/src/ui/help.rs
  • tui/src/ui/logs.rs
  • tui/src/ui/models.rs
  • tui/src/ui/pulse.rs
  • tui/src/ui/stats_bar.rs
  • tui/src/ui/tabs.rs
  • tui/src/ui/workers.rs

Comment thread tui/src/main.rs
Comment thread tui/src/state.rs
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

2 similar comments
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

key4ng added 4 commits March 26, 2026 12:16
Full-featured terminal dashboard with:

- Pulse: Worker health, throughput sparkline, request stats
- Workers: Table with running reqs, token usage, detail panel
- Chat: Streaming chat with markdown, multi-turn support
- Logs: Sub-tabs for TUI, gateway, and per-worker logs
- Stats bar: Workers, Circuit Breakers, REQ/S, AVG LATENCY
- Worker management: External providers, local sglang/vllm with GPU selection
- Gateway auto-start with IGW + round_robin

Signed-off-by: key4ng <rukeyang@gmail.com>
Signed-off-by: key4ng <rukeyang@gmail.com>
- Filter KeyEventKind::Press to prevent duplicate key handling
- Restore terminal state on setup errors (RAII-style cleanup)
- Return JoinHandle from spawn_poller for graceful shutdown
- Fix duplicate throughput_history push (use only Prometheus rps)
- Guard negative latency on Prometheus counter reset
- Derive connection state from liveness, not readiness
- Use check_alive instead of check_health for auto-start detection
- Fix Box::leak per-frame in SelectModel menu rendering
- Use circuit breaker state from Prometheus instead of worker health
- Fix UTF-8 panic in truncate_str (char-based truncation)
- Validate runtime type parse with user feedback instead of silent default
- Toggle health check actually toggles (reads current state)
- Don't block 'q' key in chat when input buffer has content
- Don't hard-code external chat models to gpt-5.4 prefix
- Roll back spawned worker on gateway registration failure
- Clean up GPU claims in :delete command
- Fix detail pane using clamped selection index
- Aggregate worker loads across all TP entries
- Update footer hints to show 1-7 views
- Fix duplicate "Navigation" header in help text
- Fix Models table "OWNER" header to "NAME"
- Use view.index() in tabs for consistency
- Add Request Stats to narrow pulse layout
- Handle zero-worker case in stats bar
- Limit file I/O in log render to last 64KB

Signed-off-by: key4ng <rukeyang@gmail.com>
- Implemented a distinction between quitting the TUI and performing a full shutdown (Ctrl+C×2).
- Updated README and help text to clarify new shutdown commands.
- Adjusted model filtering to limit OpenAI models displayed to gpt-5.4* for better manageability.
- Improved handling of worker processes during shutdown to ensure proper cleanup.

Signed-off-by: key4ng <rukeyang@gmail.com>
key4ng added 4 commits March 26, 2026 12:16
- Fix extract_port() to handle URLs with paths
- Increase auto-start readiness timeout from 30s to 120s
- Clamp gauge_bar ratio to [0.0, 1.0]
- Fix compact throughput reading wrong metric (use requests_per_sec_history)
- Fix shorten_gpu_name UTF-8 boundary issue
- Clamp selection against filtered list, not full worker list
- Surface mid-stream SSE error events in chat
- Fix logs seek UTF-8 boundary (skip partial first line)
- Fix :delete command to look up worker URL by ID from state
- Filter chat models to gpt-5.4* for OpenAI only
- q quits TUI only, Ctrl+C×2 for full shutdown
- Update README and help text for new exit behavior

Signed-off-by: key4ng <rukeyang@gmail.com>
- Updated shutdown behavior to ensure worker processes begin terminating during terminal restoration.
- Increased the time window for recognizing a second Ctrl+C from 2 seconds to 3 seconds for full shutdown.
- Added functionality to kill processes listening on a specified TCP port if the auto-started gateway is not present.
- Improved logging messages for clarity during shutdown operations.

Signed-off-by: key4ng <rukeyang@gmail.com>
- Add multi-field custom model form (model ID, TP, extra args)
  with Tab/Shift+Tab field navigation
- Extra args passed to worker launch command (e.g. --max-model-len)
- Fix logs j/k scroll: j no longer no-ops at auto-scroll bottom
- Change worker log cycle key from c to w to avoid conflict
- Update footer, help text, and README for new keybindings

Signed-off-by: key4ng <rukeyang@gmail.com>
- Modified the GPU query to check for <10% VRAM utilization instead of >2GB free memory.
- Updated the command arguments to include both used and total memory metrics.
- Adjusted the filtering logic to ensure only GPUs meeting the new criteria are returned.

Signed-off-by: key4ng <rukeyang@gmail.com>
Signed-off-by: Keyang Ru <rukeyang@gmail.com>
@key4ng
key4ng force-pushed the keyang/tui-redesign branch from 0323e8d to cad6309 Compare March 26, 2026 19:16

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cad6309ea5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tui/src/app.rs
Comment on lines +773 to +776
if self.chat_streaming {
self.chat_streaming = false;
self.chat_stream_rx = None;
if let Some(msg) = self.chat_messages.last_mut() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Abort active stream task when user presses Esc

This path marks streaming as stopped in the UI but does not cancel the spawned /v1/chat//v1/responses task, so pressing Esc during a long response still lets the upstream request run to completion (token/cost and backend load continue in the background). Because tx.send(...) errors are ignored in the stream task, dropping chat_stream_rx does not actually stop generation; store the JoinHandle (or a cancellation token) and abort on Esc.

Useful? React with 👍 / 👎.

Comment thread tui/src/state.rs
// Safety: fire-and-forget background poller that runs for the app's lifetime
#[expect(clippy::disallowed_methods)]
tokio::spawn(async move {
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(interval_secs));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard poll interval against zero

The poll period is user-controlled, and a zero value reaches Duration::from_secs(interval_secs) here, which yields an invalid polling configuration (and later rate computations divide by interval_secs). This can crash or destabilize the dashboard loop when users pass --poll-interval 0; enforce a minimum of 1 second at argument parsing or before constructing the interval.

Useful? React with 👍 / 👎.

@key4ng
key4ng merged commit cb4f567 into main Mar 26, 2026
27 checks passed
@key4ng
key4ng deleted the keyang/tui-redesign branch March 26, 2026 20:27
smfirmin pushed a commit to smfirmin/smg that referenced this pull request Apr 2, 2026
Signed-off-by: key4ng <rukeyang@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Dependency updates documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant